portage-cli 0.2.0 → 0.4.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.
@@ -0,0 +1,42 @@
1
+ require "open3"
2
+
3
+ module Portage
4
+ module Cli
5
+ class PaymentMethods
6
+ # macOS Keychain, via the `security` CLI (Open3 — array args, never an
7
+ # interpolated shell string, so an id/token containing shell metachars
8
+ # can't inject). The token is the generic-password's own secret; `id`
9
+ # is the account name, always scoped to SERVICE so it never collides
10
+ # with an unrelated Keychain entry.
11
+ class KeychainBackend
12
+ SERVICE = "portage-cli-payment".freeze
13
+
14
+ def self.available?
15
+ RUBY_PLATFORM.include?("darwin") && Portage::Cli::PaymentMethods.executable?("security")
16
+ end
17
+
18
+ # `-U` upserts rather than erroring on a pre-existing account, so
19
+ # re-enrolling under the same id just replaces the secret.
20
+ def write(id, token)
21
+ run("add-generic-password", "-a", id, "-s", SERVICE, "-w", token, "-U")
22
+ end
23
+
24
+ def read(id)
25
+ out, status = Open3.capture2("security", "find-generic-password", "-a", id, "-s", SERVICE, "-w")
26
+ status.success? ? out.chomp : nil
27
+ end
28
+
29
+ def delete(id)
30
+ run("delete-generic-password", "-a", id, "-s", SERVICE)
31
+ end
32
+
33
+ private
34
+
35
+ def run(*)
36
+ Open3.capture2("security", *)
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ require "open3"
2
+
3
+ module Portage
4
+ module Cli
5
+ class PaymentMethods
6
+ # Linux Secret Service (GNOME Keyring/KWallet via D-Bus), through the
7
+ # `secret-tool` CLI. Only reachable with a live D-Bus session — the
8
+ # headless/no-session case falls through to EnvBackend instead (see
9
+ # PaymentMethods.detect_backend), never a homegrown fallback store.
10
+ class SecretServiceBackend
11
+ SERVICE = "portage-cli-payment".freeze
12
+
13
+ def self.available?
14
+ ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != "" && Portage::Cli::PaymentMethods.executable?("secret-tool")
15
+ end
16
+
17
+ # `secret-tool store` reads the secret from stdin rather than argv,
18
+ # so it never shows up in `ps`/shell history.
19
+ def write(id, token)
20
+ Open3.capture2(
21
+ "secret-tool", "store", "--label=Portage payment method #{id}",
22
+ "service", SERVICE, "account", id, stdin_data: token
23
+ )
24
+ nil
25
+ end
26
+
27
+ def read(id)
28
+ out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", id)
29
+ status.success? ? out.chomp : nil
30
+ end
31
+
32
+ def delete(id)
33
+ Open3.capture2("secret-tool", "clear", "service", SERVICE, "account", id)
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,265 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "securerandom"
4
+ require "uri"
5
+ require "net/http"
6
+ require "portage/ucp"
7
+ require "portage/ucp/client"
8
+
9
+ # Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
10
+ # #adapter_session below — not require_relative'd here to avoid a load
11
+ # cycle (buy.rb will require this file too, for PaymentMethods.default);
12
+ # cli.rb requires "cli/buy" before "cli/payment_methods", so it's already
13
+ # loaded by the time #enroll actually runs.
14
+ require_relative "payment_methods/keychain_backend"
15
+ require_relative "payment_methods/secret_service_backend"
16
+ require_relative "payment_methods/env_backend"
17
+
18
+ module Portage
19
+ module Cli
20
+ # Card-on-file store for `portage buy`'s `--payment-token` dead-end
21
+ # (buy.rb) — three backend tiers, picked once per process by
22
+ # .detect_backend, no homegrown crypto or fallback file store of our
23
+ # own (docs/plans/agentic-payments.md Phase 1):
24
+ #
25
+ # 1. macOS Keychain (KeychainBackend, shells out to `security`)
26
+ # 2. Linux Secret Service (SecretServiceBackend, shells out to
27
+ # `secret-tool`) — only when a D-Bus session is actually live
28
+ # 3. Headless (EnvBackend) — no local storage; the token comes
29
+ # straight from PORTAGE_PAYMENT_TOKEN
30
+ #
31
+ # The secret itself lives in the backend; this class only keeps
32
+ # non-secret bookkeeping (label/default/frozen) in
33
+ # ~/.portage/payment_methods.json — irrelevant for the headless tier,
34
+ # which has no ids to track at all.
35
+ #
36
+ # Local policy guards agent mistakes, not a compromised agent: anyone
37
+ # running as the local user can edit payment_methods.json directly, so
38
+ # the real backstop against a rogue/compromised agent is an issuer-side
39
+ # limit (a virtual card via Stripe Issuing, Privacy.com, etc.), not this
40
+ # file.
41
+ class PaymentMethods
42
+ PATH = File.join(Dir.home, ".portage", "payment_methods.json").freeze
43
+
44
+ class UnknownMethodError < StandardError; end
45
+
46
+ def self.executable?(name)
47
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
48
+ File.executable?(File.join(dir, name)) && !File.directory?(File.join(dir, name))
49
+ end
50
+ end
51
+
52
+ def self.detect_backend
53
+ return KeychainBackend.new if KeychainBackend.available?
54
+ return SecretServiceBackend.new if SecretServiceBackend.available?
55
+
56
+ EnvBackend.new
57
+ end
58
+
59
+ def self.default(path: PATH, backend: detect_backend) = new(path: path, backend: backend).default
60
+
61
+ def initialize(path: PATH, backend: self.class.detect_backend)
62
+ @path = path
63
+ @backend = backend
64
+ end
65
+
66
+ # @return [String, nil] the token `portage buy` should use when
67
+ # `--payment-token` was omitted, or nil if there isn't one it can
68
+ # safely use (nothing enrolled, or the only default is frozen).
69
+ def default
70
+ return @backend.read(nil) if headless?
71
+
72
+ entry = store["methods"].find { |m| m["default"] }
73
+ return nil if entry.nil? || entry["frozen"]
74
+
75
+ @backend.read(entry["id"])
76
+ end
77
+
78
+ # @return [Array<Hash>] non-secret metadata only — never the token.
79
+ def list
80
+ return [] if headless?
81
+
82
+ store["methods"]
83
+ end
84
+
85
+ def make_default(id)
86
+ entry = find!(id)
87
+ store["methods"].each { |m| m["default"] = (m["id"] == id) }
88
+ write
89
+ entry
90
+ end
91
+
92
+ # `remove` and `revoke` are the same hard delete (metadata entry +
93
+ # backend secret, both gone) — the plan names them separately, but
94
+ # with no processor-side "invalidate this token" capability to defer
95
+ # to, there's no real distinction to invent between "remove" and
96
+ # "delete the token" beyond the name. Both exist as CLI subcommands so
97
+ # either reads naturally in the moment ("get rid of this" vs. "this
98
+ # card's compromised, kill it").
99
+ def remove(id)
100
+ entry = find!(id)
101
+ store["methods"].reject! { |m| m["id"] == id }
102
+ write
103
+ @backend.delete(id)
104
+ entry
105
+ end
106
+ alias revoke remove
107
+
108
+ # Blocks spend without forgetting the enrollment — `default` returns
109
+ # nil for a frozen default, but the metadata entry (and backend
110
+ # secret) stay put. No "unfreeze" — the plan draws the line at v1
111
+ # only needing revoke to fully undo an enrollment.
112
+ def freeze_method(id)
113
+ entry = find!(id)
114
+ entry["frozen"] = true
115
+ write
116
+ entry
117
+ end
118
+
119
+ # Starts a browser-handoff enrollment against `url` (native UCP
120
+ # manifest, or the own-store adapter loopback — same discovery
121
+ # buy.rb#call uses) and blocks polling
122
+ # app.portage-ucp.payment_enrollment until the gateway-hosted setup
123
+ # page resolves to a token or `timeout` elapses. Prints nothing
124
+ # itself — callers (Cli.run_payment_enroll) own presentation.
125
+ #
126
+ # Yields the gateway-hosted `setup_url` to the given block as soon as
127
+ # it's known (before polling starts) — the caller's one chance to show
128
+ # it, since this method otherwise doesn't return until "complete" or
129
+ # `timeout` elapses.
130
+ #
131
+ # @return [Hash] {status:, setup_url:, id:, label:} — status is
132
+ # "complete", "pending" (timed out — the CLI can re-poll later
133
+ # against the same enrollment id), or "unsupported" (nothing at
134
+ # `url` advertises payment enrollment).
135
+ # @param scope [Hash, nil] Phase 2 per-token policy scope, bound at
136
+ # enrollment time (docs/plans/agentic-payments.md) — e.g.
137
+ # `{merchants: ["shop.example.com"], max_amount: 5000, currency: "USD"}`.
138
+ # Written to Portage::Ucp::Policy keyed by the same token_ref
139
+ # PolicyGuard derives from the token at charge time, never to
140
+ # payment_methods.json — policy config is portage-ucp's file, not
141
+ # this gem's.
142
+ def enroll(url, label: nil, scope: nil, poll_interval: 3, timeout: 300, sleeper: ->(s) { sleep(s) })
143
+ raise NotSupportedError, "headless mode has no local storage — set PORTAGE_PAYMENT_TOKEN instead" if headless?
144
+
145
+ session = discover_session(url)
146
+ return { status: "unsupported" } unless session && payment_enrollment_advertised?(session)
147
+
148
+ enrollment = session.create_payment_enrollment
149
+ yield enrollment["setup_url"] if block_given?
150
+ poll_until_resolved(session, enrollment, label, scope, poll_interval, timeout, sleeper)
151
+ rescue Portage::Ucp::Client::Error
152
+ # Capability not actually there despite #advertises? being nil
153
+ # (own-store adapter loopback doesn't know capabilities upfront —
154
+ # see Session#advertises?) — the adapter simply never registered the
155
+ # tool, surfaced as a client-side error rather than a Ruby NoMethodError.
156
+ { status: "unsupported" }
157
+ end
158
+
159
+ private
160
+
161
+ def headless?
162
+ @backend.is_a?(EnvBackend)
163
+ end
164
+
165
+ def poll_until_resolved(session, enrollment, label, scope, poll_interval, timeout, sleeper)
166
+ deadline = Time.now + timeout
167
+ current = enrollment
168
+ until current["status"] == "complete" || Time.now >= deadline
169
+ sleeper.call(poll_interval)
170
+ current = session.get_payment_enrollment(enrollment_id: current["id"])
171
+ return { status: "unsupported" } unless current
172
+ end
173
+ return { status: "pending", setup_url: enrollment["setup_url"], id: enrollment["id"] } unless
174
+ current["status"] == "complete"
175
+
176
+ { status: "complete", **enroll_locally(current["payment_token"], label, scope) }
177
+ end
178
+
179
+ def enroll_locally(token, label, scope)
180
+ id = SecureRandom.uuid
181
+ @backend.write(id, token)
182
+ entry = { "id" => id, "label" => label || id, "frozen" => false,
183
+ "default" => store["methods"].empty?, "created_at" => Time.now.utc.iso8601 }
184
+ store["methods"] << entry
185
+ write
186
+ set_token_scope(token, scope) if scope
187
+ { id: id, label: entry["label"] }
188
+ end
189
+
190
+ def set_token_scope(token, scope)
191
+ token_ref = Portage::Ucp::Support::TokenRef.for(token)
192
+ Portage::Ucp::Policy.load.set_token_scope(token_ref, stringify_keys(scope))
193
+ end
194
+
195
+ def stringify_keys(hash) = hash.transform_keys(&:to_s)
196
+
197
+ # Same native-manifest-first, own-store-adapter-fallback discovery as
198
+ # Buy#call — duplicated rather than extracted since Buy's version is
199
+ # entangled with cart/checkout-specific branching this only needs the
200
+ # session object from.
201
+ def discover_session(url)
202
+ native = Portage::Ucp::Client.discover(url)
203
+ native if native
204
+ rescue Portage::Ucp::Client::DiscoveryError
205
+ adapter_session(url)
206
+ end
207
+
208
+ def adapter_session(url)
209
+ uri = URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
210
+ body, headers = fetch_homepage(uri)
211
+ platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
212
+ return nil unless platform
213
+
214
+ env = Portage::Ucp::Resolver.env_for(platform)
215
+ return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
216
+
217
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
218
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: Buy::PermissiveAuthenticator.new)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+
223
+ def fetch_homepage(uri)
224
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
225
+ open_timeout: 5, read_timeout: 5) do |http|
226
+ http.get(uri.request_uri, { "User-Agent" => "portage-payment-enroll" })
227
+ end
228
+ response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
229
+ rescue StandardError
230
+ [nil, {}]
231
+ end
232
+
233
+ def payment_enrollment_advertised?(session)
234
+ session.advertises?("app.portage-ucp.payment_enrollment") != false
235
+ end
236
+
237
+ def find!(id)
238
+ store["methods"].find { |m| m["id"] == id } || raise(UnknownMethodError, id)
239
+ end
240
+
241
+ def store
242
+ @store ||= read
243
+ end
244
+
245
+ def read
246
+ parsed = File.readable?(@path) ? JSON.parse(File.read(@path)) : {}
247
+ parsed = {} unless parsed.is_a?(Hash)
248
+ { "methods" => Array(parsed["methods"]) }
249
+ rescue StandardError
250
+ { "methods" => [] }
251
+ end
252
+
253
+ # Unlike History#write, a failed write on the payment path is fatal
254
+ # (docs/plans/agentic-payments.md's "transaction log writes are fatal
255
+ # on the payment path" applies here too — a swallowed write here would
256
+ # silently un-set/re-set a default or forget a freeze) — raises
257
+ # rather than the `rescue StandardError; nil` pattern history.rb uses.
258
+ def write
259
+ FileUtils.mkdir_p(File.dirname(@path))
260
+ File.write(@path, JSON.generate(@store))
261
+ File.chmod(0o600, @path)
262
+ end
263
+ end
264
+ end
265
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.2.0".freeze
3
+ VERSION = "0.4.0".freeze
4
4
  end
5
5
  end