portage-cli 0.6.4 → 0.7.3

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.
@@ -1,5 +1,6 @@
1
1
  require "uri"
2
2
  require_relative "config"
3
+ require_relative "setting"
3
4
 
4
5
  module Portage
5
6
  module Cli
@@ -8,10 +9,10 @@ module Portage
8
9
  # (escalation, permission denied, no payment token) hands off a link
9
10
  # rather than completing the purchase itself.
10
11
  #
11
- # Default off. Precedence for the toggle (open decision #1, resolved):
12
- # a per-invocation `auto_open:` override (portage buy --auto-open /
13
- # --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which beats
14
- # ~/.portage/config.json's "auto_open_checkout" (Config).
12
+ # Default off. Precedence for the toggle (open decision #1, resolved,
13
+ # see Setting): a per-invocation `auto_open:` override (portage buy
14
+ # --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
15
+ # beats ~/.portage/config.json's "auto_open_checkout" (Config).
15
16
  #
16
17
  # No new gem for the actual open — every other shell-out in this repo
17
18
  # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
@@ -22,7 +23,6 @@ module Portage
22
23
  class CheckoutHandoff
23
24
  ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
24
25
  CONFIG_KEY = "auto_open_checkout".freeze
25
- TRUE_VALUES = %w[1 true yes].freeze
26
26
 
27
27
  def initialize(auto_open: nil, config: Config.load)
28
28
  @override = auto_open
@@ -30,12 +30,7 @@ module Portage
30
30
  end
31
31
 
32
32
  def auto_open?
33
- return @override unless @override.nil?
34
-
35
- env = env_override
36
- return env unless env.nil?
37
-
38
- !!@config.get(CONFIG_KEY)
33
+ Setting.flag?(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
39
34
  end
40
35
 
41
36
  # @return [Boolean] whether the browser was actually opened.
@@ -47,13 +42,6 @@ module Portage
47
42
 
48
43
  private
49
44
 
50
- def env_override
51
- raw = ENV.fetch(ENV_VAR, nil)
52
- return nil if raw.nil?
53
-
54
- TRUE_VALUES.include?(raw.downcase)
55
- end
56
-
57
45
  def https?(url)
58
46
  URI.parse(url).scheme == "https"
59
47
  rescue URI::InvalidURIError
@@ -0,0 +1,127 @@
1
+ require "json"
2
+ require_relative "setting"
3
+ require_relative "decisions"
4
+
5
+ module Portage
6
+ module Cli
7
+ # The confidence gate (docs/plans/system-one-decision-layer.md
8
+ # § Responsibilities 3) as `portage buy` uses it: one yes/no question put
9
+ # to a Decision::ModelBackends backend right before an unattended
10
+ # (`--yes`) completion — "is this checkout what the shopper asked for,
11
+ # and safe to complete without a person looking at it?"
12
+ #
13
+ # Default off. Nothing asks a model anything unless a backend is named,
14
+ # via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
15
+ # `laya`, Decision::ModelBackends::REGISTRY's keys). The threshold comes
16
+ # from `--min-confidence N` or PORTAGE_MIN_CONFIDENCE, else
17
+ # DEFAULT_THRESHOLD.
18
+ #
19
+ # This answers the plan's open question about how the gate relates to
20
+ # `Confirmer`: it runs in front of the completion, after `--yes` and the
21
+ # local PolicyCheck, and never in place of either. A low score holds the
22
+ # purchase and hands the checkout to the shopper. It never
23
+ # auto-approves anything `--yes` wouldn't already have allowed.
24
+ #
25
+ # Fails closed. An unknown backend name, a backend that isn't configured
26
+ # (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
27
+ # the purchase, the same as a low score does. So does naming a backend
28
+ # without portage-ucp-decision installed: the backends live in that
29
+ # optional gem. Once a caller has asked for a confidence check, the
30
+ # check never passing is not "no opinion".
31
+ class ConfidenceCheck
32
+ BACKEND_ENV = "PORTAGE_DECISION_BACKEND".freeze
33
+ THRESHOLD_ENV = "PORTAGE_MIN_CONFIDENCE".freeze
34
+ DEFAULT_THRESHOLD = 0.8
35
+ QUESTION = "safe_to_complete".freeze
36
+ # Phrased so "yes" means "proceed": a noul answer's value is the
37
+ # probability of yes, and that is what ConfidenceGate thresholds.
38
+ INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
39
+ "query, the merchant, the requested quantity, the checkout's line items and totals, and " \
40
+ "any warnings about where the checkout differs from the request. Answer yes only if the " \
41
+ "checkout clearly matches what the shopper asked for and is safe to complete without a " \
42
+ "person reviewing it first.".freeze
43
+
44
+ # @param backend [String, nil] a ModelBackends::REGISTRY key; nil
45
+ # defers to PORTAGE_DECISION_BACKEND.
46
+ # @param threshold [Float, nil] 0.0..1.0; nil defers to
47
+ # PORTAGE_MIN_CONFIDENCE, then DEFAULT_THRESHOLD.
48
+ # @param resolver [#call, nil] builds a backend from its name —
49
+ # injectable so specs never reach a real model. nil means
50
+ # ModelBackends.resolve, looked up only once a backend is needed.
51
+ # @raise [ArgumentError] for a threshold outside 0.0..1.0. An explicit
52
+ # `threshold:` is always checked, so a mistyped `--min-confidence`
53
+ # fails loudly. PORTAGE_MIN_CONFIDENCE is read, and so checked,
54
+ # only once a backend is enabled: a stale value in a shell that
55
+ # never uses the gate mustn't stop every `portage buy`.
56
+ def initialize(backend: nil, threshold: nil, resolver: nil)
57
+ @backend_name = Setting.resolve(override: backend, env: BACKEND_ENV)&.to_s&.strip
58
+ raw_threshold = enabled? ? Setting.resolve(override: threshold, env: THRESHOLD_ENV) : threshold
59
+ @threshold = parse_threshold(raw_threshold || DEFAULT_THRESHOLD)
60
+ @resolver = resolver
61
+ end
62
+
63
+ attr_reader :threshold
64
+
65
+ def enabled? = !@backend_name.nil?
66
+
67
+ # @param state [Hash] JSON-serializable. Never pass it a payment token.
68
+ # @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
69
+ # `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
70
+ # `reason` is nil when it proceeds, else why it held:
71
+ # `below_threshold`, `backend_error` (unknown, unconfigured or
72
+ # failed backend) or `not_installed` (no portage-ucp-decision).
73
+ # `error` is the detail behind the last two, nil otherwise.
74
+ def call(state)
75
+ return nil unless enabled?
76
+ return held("not_installed", not_installed_message) unless Decisions.available?
77
+
78
+ verdict = Portage::Ucp::Decision::ConfidenceGate.via_backend(
79
+ backend: resolve_backend, state: JSON.generate(state), question: QUESTION,
80
+ instructions: INSTRUCTIONS, threshold: @threshold
81
+ )
82
+ { proceed: verdict.proceed, reason: verdict.proceed ? nil : "below_threshold",
83
+ confidence: verdict.confidence, threshold: @threshold, backend: @backend_name, error: nil }
84
+ rescue StandardError => e
85
+ # Any failure, not only Decision::Error: this runs after a real
86
+ # checkout exists, so an escaped exception would drop the report,
87
+ # the hand-off and the history entry along with the purchase.
88
+ held("backend_error", e.message)
89
+ end
90
+
91
+ # What `portage doctor` reports: why the named backend would hold every
92
+ # `--yes` purchase, before one is attempted.
93
+ # @return [String, nil] nil when disabled or ready to answer.
94
+ def configuration_problem
95
+ return nil unless enabled?
96
+ return not_installed_message unless Decisions.available?
97
+
98
+ resolve_backend.configuration_problem
99
+ rescue Portage::Ucp::Decision::Error => e
100
+ e.message
101
+ end
102
+
103
+ private
104
+
105
+ def resolve_backend
106
+ (@resolver || Portage::Ucp::Decision::ModelBackends.method(:resolve)).call(@backend_name)
107
+ end
108
+
109
+ def held(reason, error)
110
+ { proceed: false, reason: reason, confidence: nil, threshold: @threshold, backend: @backend_name,
111
+ error: error }
112
+ end
113
+
114
+ def not_installed_message
115
+ "portage-ucp-decision is not installed — `gem install portage-ucp-decision` to use the " \
116
+ "#{@backend_name} confidence check."
117
+ end
118
+
119
+ def parse_threshold(raw)
120
+ value = Float(raw, exception: false)
121
+ return value if value&.between?(0.0, 1.0)
122
+
123
+ raise ArgumentError, "confidence threshold must be a number between 0.0 and 1.0, got #{raw.inspect}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,72 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # portage-cli's judgment calls (docs/plans/system-one-decision-layer.md).
6
+ # The rules live in portage-ucp core: Support::OfferRanking,
7
+ # Support::Escalation and PolicyGuard. portage-ucp-decision's
8
+ # OfferRanking, EscalationPolicy and PolicyCheck are typed wrappers
9
+ # around those same modules, so `buy` and `find` answer the same way
10
+ # whether or not that gem is installed.
11
+ #
12
+ # Every method returns a plain Hash whose `reason` is a String or nil,
13
+ # so a caller sees the same values in-process as in `--json`.
14
+ #
15
+ # Only the confidence gate needs the optional gem, because the model
16
+ # backends live there (see ConfidenceCheck and .available?).
17
+ module Decisions
18
+ # Loaded the way Resolver.build_adapter's callers treat an optional
19
+ # adapter gem: `require`, then rescue LoadError. It isn't in the
20
+ # gemspec, so `gem install portage-cli` stays light.
21
+ # @return [Boolean] whether portage-ucp-decision could be loaded.
22
+ # Memoized: `require` runs once per process.
23
+ def self.available?
24
+ return @available unless @available.nil?
25
+
26
+ @available = begin
27
+ require "portage/ucp/decision"
28
+ true
29
+ rescue LoadError
30
+ false
31
+ end
32
+ end
33
+
34
+ # @param offers [Array<Hash>] with `:checkout` (buyable) and `:amount`.
35
+ def self.rank(offers)
36
+ Portage::Ucp::Support::OfferRanking.rank(offers) { |offer| [offer[:checkout], offer[:amount]] }
37
+ end
38
+
39
+ # @param warnings [Array<String>] mismatches that should escalate —
40
+ # pass none when mismatches are only to be reported.
41
+ # @return [Hash] `escalate:`, `reason:`.
42
+ def self.escalation(checkout_status:, warnings: [])
43
+ reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status, warnings: warnings)
44
+ { escalate: !reason.nil?, reason: reason&.to_s }
45
+ end
46
+
47
+ # The buyer's spend policy, checked by PolicyGuard.
48
+ #
49
+ # PolicyGuard skips both spend caps when `amount` is nil, which suits
50
+ # its own caller (an adapter that can't price a checkout up front).
51
+ # Here it would let a checkout with no `total` line past a configured
52
+ # cap while reporting `allowed: true`, so a missing total is denied as
53
+ # `total_unknown` whenever a cap exists.
54
+ # @param transaction_log [Portage::Ucp::Support::TransactionLog] what
55
+ # the rolling cap and velocity limit count.
56
+ # @return [Hash] `allowed:`, `reason:`.
57
+ def self.policy(amount:, currency:, merchant:, token_ref:,
58
+ transaction_log: Portage::Ucp::Support::TransactionLog.new)
59
+ policy = Portage::Ucp::Policy.load
60
+ if amount.nil? && (policy.per_transaction_cap || policy.rolling_cap)
61
+ return { allowed: false, reason: "total_unknown" }
62
+ end
63
+
64
+ Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
65
+ token_ref: token_ref, policy: policy, transaction_log: transaction_log)
66
+ { allowed: true, reason: nil }
67
+ rescue Portage::Ucp::PolicyViolationError => e
68
+ { allowed: false, reason: e.reason.to_s }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,4 +1,8 @@
1
1
  require "portage/ucp"
2
+ require "uri"
3
+ require_relative "confidence_check"
4
+ require_relative "user_agent"
5
+ require_relative "proxy_settings"
2
6
 
3
7
  module Portage
4
8
  module Cli
@@ -14,8 +18,9 @@ module Portage
14
18
  class Doctor
15
19
  Finding = Struct.new(:check, :message, keyword_init: true)
16
20
 
17
- def initialize(adapter_class: nil)
21
+ def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
18
22
  @adapter_class = adapter_class
23
+ @proxy_settings = proxy_settings
19
24
  end
20
25
 
21
26
  def call
@@ -24,6 +29,10 @@ module Portage
24
29
  rate_limiter_finding,
25
30
  signing_keys_finding,
26
31
  payment_handlers_finding,
32
+ decision_backend_finding,
33
+ user_agent_finding,
34
+ proxy_finding,
35
+ *proxy_doctor_findings,
27
36
  *capability_findings
28
37
  ].compact
29
38
  end
@@ -54,6 +63,95 @@ module Portage
54
63
  Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
55
64
  end
56
65
 
66
+ # The confidence gate's backend, checked only once one is selected
67
+ # (PORTAGE_DECISION_BACKEND): the gate is off by default, and a
68
+ # missing key for a backend nobody chose is noise. Once one is
69
+ # selected, anything short of ready holds every `--yes` purchase, so
70
+ # it's flagged here rather than first surfacing mid-checkout. Covers
71
+ # the gem not being installed, an unknown backend name, a missing
72
+ # JEV_API_KEY, a missing Laya bridge, and a bad PORTAGE_MIN_CONFIDENCE.
73
+ def decision_backend_finding
74
+ problem = ConfidenceCheck.new.configuration_problem
75
+ problem && Finding.new(check: "decision_backend",
76
+ message: "#{problem} — until then every `portage buy --yes` is held for the shopper.")
77
+ rescue ArgumentError => e
78
+ Finding.new(check: "decision_backend", message: e.message)
79
+ end
80
+
81
+ # PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
82
+ # as-is on every outbound request. Net::HTTP raises ArgumentError on a
83
+ # header value containing CR/LF rather than send it, so a bad override
84
+ # would otherwise surface for the first time mid-checkout instead of
85
+ # here.
86
+ def user_agent_finding
87
+ value = Portage::Cli::UserAgent.value
88
+ return unless value =~ /[\r\n]/
89
+
90
+ Finding.new(check: "user_agent",
91
+ message: "Configured User-Agent (PORTAGE_USER_AGENT or user_agent in " \
92
+ "~/.portage/config.json) contains a newline — every outbound request will " \
93
+ "raise instead of sending.")
94
+ end
95
+
96
+ # Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
97
+ # site in portage-cli/portage-ucp/the adapter gems resolves its proxy
98
+ # from Ruby stdlib's own `:ENV` default, which — confirmed against a
99
+ # real local proxy — only ever reads `http_proxy`/`HTTP_PROXY`, for
100
+ # *both* http and https targets, and never `https_proxy`/`HTTPS_PROXY`.
101
+ # That's surprising enough (and the failure silent enough — requests
102
+ # just go direct) that it's worth a doctor check on both sides: report
103
+ # the effective proxy when one is actually active, and flag the classic
104
+ # footgun of setting only HTTPS_PROXY and expecting it to do anything
105
+ # here.
106
+ def proxy_finding
107
+ http_proxy = ENV["http_proxy"] || ENV.fetch("HTTP_PROXY", nil)
108
+ return https_only_proxy_finding if !http_proxy && https_env_proxy_set?
109
+
110
+ return unless http_proxy
111
+
112
+ Finding.new(check: "proxy",
113
+ message: "Outbound requests proxy through #{redact_proxy_url(http_proxy)} " \
114
+ "(from #{ENV['http_proxy'] ? 'http_proxy' : 'HTTP_PROXY'}, used for both http:// " \
115
+ "and https:// targets)#{no_proxy_suffix}.")
116
+ end
117
+
118
+ def https_only_proxy_finding
119
+ Finding.new(check: "proxy",
120
+ message: "HTTPS_PROXY/https_proxy is set but http_proxy/HTTP_PROXY is not — Ruby's " \
121
+ "Net::HTTP only ever reads http_proxy for its :ENV proxy mode (for both http:// " \
122
+ "and https:// targets), so every portage-cli/portage-ucp/adapter call site is " \
123
+ "proxying nothing right now. Set http_proxy (it covers https:// targets too) if " \
124
+ "that traffic should go through a proxy. (portage-ucp-client's own UCP/MCP tool " \
125
+ "calls are the one exception — they're Faraday-based and do read https_proxy.)")
126
+ end
127
+
128
+ def https_env_proxy_set?
129
+ !(ENV["https_proxy"].to_s.empty? && ENV["HTTPS_PROXY"].to_s.empty?)
130
+ end
131
+
132
+ def no_proxy_suffix
133
+ no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
134
+ no_proxy ? "; no_proxy=#{no_proxy}" : ""
135
+ end
136
+
137
+ # `http://***@host:port` — never the real credentials (see the plan's
138
+ # "Credentials stay safe" constraint).
139
+ def redact_proxy_url(url)
140
+ uri = URI.parse(url.include?("://") ? url : "http://#{url}")
141
+ userinfo = uri.userinfo ? "***@" : ""
142
+ port = uri.port ? ":#{uri.port}" : ""
143
+ "#{uri.scheme}://#{userinfo}#{uri.host}#{port}"
144
+ rescue URI::InvalidURIError
145
+ "(unparseable proxy URL)"
146
+ end
147
+
148
+ # Phase 2's own checks against whatever ProxySettings resolved
149
+ # (flags/env/config.json), kept separate from proxy_finding above —
150
+ # see ProxyDoctor's own comment for why the two don't merge.
151
+ def proxy_doctor_findings
152
+ ProxyDoctor.new(proxy_settings: @proxy_settings).findings
153
+ end
154
+
57
155
  def payment_handlers_finding
58
156
  return unless Array(config.payment_handlers).empty?
59
157
 
@@ -89,3 +187,8 @@ module Portage
89
187
  end
90
188
  end
91
189
  end
190
+
191
+ # Loaded after Doctor closes — ProxyDoctor calls Doctor::Finding.new, so
192
+ # Doctor has to exist first (this file requires proxy_settings, not
193
+ # proxy_doctor, at the top for exactly this reason).
194
+ require_relative "proxy_doctor"
@@ -4,6 +4,8 @@ require "portage/ucp/client"
4
4
 
5
5
  require_relative "search_backends"
6
6
  require_relative "probe_cache"
7
+ require_relative "decisions"
8
+ require_relative "user_agent"
7
9
 
8
10
  module Portage
9
11
  module Cli
@@ -127,7 +129,7 @@ module Portage
127
129
  end
128
130
 
129
131
  def discover(origin)
130
- Portage::Ucp::Client.discover(origin)
132
+ Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
131
133
  rescue StandardError
132
134
  nil
133
135
  end
@@ -166,14 +168,13 @@ module Portage
166
168
  amount: amount, currency: currency, url: field(product, "url") }
167
169
  end
168
170
 
169
- # Buyable first, then cheapest, then unpriced. Sorting on price alone
170
- # would float a browse-only store above one you can actually check out
171
- # from, which is the wrong answer to "buy me this".
172
- def rank(offers)
173
- offers.sort_by do |offer|
174
- [offer[:checkout] ? 0 : 1, offer[:amount] ? 0 : 1, offer[:amount] || 0]
175
- end
176
- end
171
+ # Buyable first, then cheapest, then unpriced (see Decisions.rank —
172
+ # core's Support::OfferRanking, the rule portage-ucp-decision's
173
+ # OfferRanking wraps), so an agent loop ranking its own candidate list
174
+ # gets the same order this command prints. Sorting on price alone
175
+ # would float a browse-only store above one you can actually check
176
+ # out from, which is the wrong answer to "buy me this".
177
+ def rank(offers) = Decisions.rank(offers)
177
178
 
178
179
  # --- Shapes ---
179
180
 
@@ -0,0 +1,50 @@
1
+ module Portage
2
+ module Cli
3
+ class HandoffReconciler
4
+ # Adapts a wire hash (from #get_checkout/#get_order) to what
5
+ # OrderLedger#record/PurchaseJournal#record_checkout duck-type against
6
+ # (Portage::Ucp::Order/Checkout value objects) — see
7
+ # docs/plans/handoff-reconcile.md, "The journal needs a
8
+ # hash-to-value-object step". Deliberately not a second, parallel
9
+ # entry shape: both still go through PurchaseJournal#record_checkout /
10
+ # OrderLedger#record's own logic (`to_wire_h`) — these just wrap the
11
+ # already-wire-shaped hash to answer the handful of methods each one
12
+ # calls.
13
+ WireOrder = Struct.new(:wire) do
14
+ def id = wire["id"]
15
+ def to_wire_h = wire
16
+ end
17
+
18
+ WireCheckout = Struct.new(:wire) do
19
+ def currency = wire["currency"]
20
+ def order = wire["order"] && WireOrderRef.new(wire["order"])
21
+
22
+ def line_items
23
+ Array(wire["line_items"]).map { |li| WireLineItem.new(li) }
24
+ end
25
+ end
26
+
27
+ WireOrderRef = Struct.new(:wire) do
28
+ def id = wire["id"]
29
+ end
30
+
31
+ WireLineItem = Struct.new(:wire) do
32
+ def item = WireItem.new(wire["item"] || {})
33
+ def quantity = wire["quantity"]
34
+
35
+ def totals
36
+ Array(wire["totals"]).map { |t| WireTotal.new(t) }
37
+ end
38
+ end
39
+
40
+ WireItem = Struct.new(:wire) do
41
+ def id = wire["id"]
42
+ end
43
+
44
+ WireTotal = Struct.new(:wire) do
45
+ def type = wire["type"]
46
+ def amount = wire["amount"]
47
+ end
48
+ end
49
+ end
50
+ end