Setting the file. One moment.
Agents Pay Admin · Agents Pay · aws/agent-toolkit-for-aws · Skills Docs
Repo No. 14 · Agents Pay
↖ Back to the coverEnd User Computing Skills
Messaging And Streaming Skills
Migration And Modernization Skills
Networking And Content Delivery Skills
Security And Identity Skills
Web And Mobile Development
132 skills · 818 min
ContentsBack to the top of the page 70
Creating Amazon Aurora Db Cluster With Instances
93
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
def resolve_user_id
— line 276
This file
Number 21.6
Position 6 of 14
Type Python
Size 45 KB
Lines 1,107 scripts/ agents_pay_admin.py
Python · 1,107 lines · 45 KB
15
Why the split matters
16 ---------------------
17 Administrative actions are not model-callable tools, take no model input, and
18 this file is never imported by the runtime fetch path. A payment-capable model
19 therefore cannot mint fresh budget or receive provider credentials through this
20 interface.
21
22 Commands
23 --------
24 init-config Write ~/.agents-pay/config.json (0600) — resources + policy
25 show-config Print the active config and its file permissions
26 create-instrument Create the per-user wallet (ManagementRole)
27 new-session Create a budget-bounded session (ManagementRole, TTY approval)
28 preflight Verify wiring and confirm no secrets are exposed
29
30 Secrets are NEVER accepted as arguments here either. Provider credentials go to
31 the AgentCore CLI's own interactive wizard (`agentcore add payment-connector`),
32 which keeps them out of shell history and out of this process.
33 """
34
35 from __future__ import annotations
36
37 import argparse
38 import json
39 import os
40 import secrets
41 import stat
42 import sys
43 import tempfile
44 from decimal import Decimal, InvalidOperation
45 from pathlib import Path
46
47
48 def _check_aws_credentials (region: str | None = None ) -> bool :
49 """Fail fast with a clear message if AWS credentials are missing/expired.
50
51 A cheap, read-only STS call (no IAM permissions beyond the default caller
52 identity) run BEFORE any interactive prompts. Without this, a user can type
53 through the entire setup-openclaw wizard only to discover at instrument or
54 session creation — several prompts later — that their credentials expired,
55 forcing a full re-run. boto3 is already a hard dependency of
56 bedrock-agentcore, so this adds no new dependency.
57 """
58 try :
59 import boto3
60 from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
61 except ImportError :
62 # boto3 missing entirely is caught by the bedrock_agentcore.payments
63 # import check that every caller already performs; nothing to add here.
64 return True
65 try :
66 sts = boto3.client( "sts" , region_name = region or os.environ.get( "AWS_REGION" , "us-east-1" ))
67 sts.get_caller_identity()
68 return True
69 except (NoCredentialsError, ClientError, BotoCoreError) as exc:
70 print (
71 "AWS credentials are invalid, expired, or missing. \n "
72 f " ( { exc } ) \n\n "
73 "Run `aws sso login` (or otherwise refresh your credentials), then "
74 "re-run this command." ,
75 file = sys.stderr,
76 )
77 return False
78
79 DEFAULT_DIR = Path.home() / ".agents-pay"
80 DEFAULT_CONFIG = DEFAULT_DIR / "config.json"
81 AGENT_NAME = "aws-agents-pay"
82 USDC_DECIMALS = 6
83
84
85 def admin_config_path (explicit: str | None ) -> Path:
86 """Resolve an operator-selected path for administrative commands only."""
87 return Path(explicit or os.environ.get( "AGENTS_PAY_CONFIG" ) or DEFAULT_CONFIG )
88
89
90 def deployed_state_candidates (project_dir: str | Path | None = None ) -> list[Path]:
91 """Paths where the AgentCore CLI may record deployed payment resources."""
92 explicit = project_dir or os.environ.get( "AGENTCORE_PROJECT_DIR" )
93 if explicit:
94 root = Path(explicit).expanduser().resolve()
95 return [
96 root / "agentcore/.cli/deployed-state.json" ,
97 root / ".cli/deployed-state.json" ,
98 ]
99 return [
100 Path( "agentcore/.cli/deployed-state.json" ), # project root
101 Path( ".cli/deployed-state.json" ), # inside agentcore/
102 Path( "../.cli/deployed-state.json" ), # child of agentcore/
103 ]
104
105
106 def _find_deployed_state (project_dir: str | Path | None = None ) -> Path | None :
107 """Return the first existing deployed-state.json candidate, or None."""
108 for candidate in deployed_state_candidates(project_dir):
109 if candidate.exists():
110 return candidate
111 return None
112
113
114 def discover_deployed (project_dir: str | Path | None = None ) -> dict[ str , str | None ]:
115 """Best-effort read of manager ARN / connector ID from the CLI's deploy record.
116
117 Returns a dict with possibly-None values; callers fall back to flags or env.
118 Purely a convenience: nothing security-relevant is decided from this file, and
119 a wrong or missing value surfaces as a plain error from the service.
120
121 Searches several relative paths so the command works whether you run from the
122 project root, inside the agentcore/ directory, or a subdirectory of it.
123
124 CLI 0.20.x writes targets.<target>.resources.payments[]; 0.26.x writes
125 payments as objects keyed by name. Older layouts used a top-level payments[].
126 All three are handled.
127 """
128 out: dict[ str , str | None ] = { "manager_arn" : None , "connector_id" : None , "role_arn" : None }
129 state_path = _find_deployed_state(project_dir)
130 if state_path is None :
131 return out
132 try :
133 data = json.loads(state_path.read_text())
134 payments = None
135 targets = data.get( "targets" ) or {}
136 target = targets.get( "default" ) or ( next ( iter (targets.values()), {}) if targets else {})
137 if isinstance (target, dict ):
138 payments = (target.get( "resources" ) or {}).get( "payments" )
139 if not payments:
140 payments = data.get( "payments" )
141 if not payments:
142 return out
143 # 0.26.x: payments is a dict keyed by name
144 if isinstance (payments, dict ):
145 pay = next ( iter (payments.values()), {})
146 # 0.20.x and older: payments is a list
147 elif isinstance (payments, list ):
148 pay = payments[ 0 ] if payments else {}
149 else :
150 return out
151 connectors = pay.get( "connectors" ) or []
152 out[ "manager_arn" ] = pay.get( "managerArn" )
153 # 0.26.x: connectors may be a dict keyed by name
154 if isinstance (connectors, dict ):
155 first_connector = next ( iter (connectors.values()), {})
156 elif isinstance (connectors, list ):
157 first_connector = connectors[ 0 ] if connectors else {}
158 else :
159 first_connector = {}
160 out[ "connector_id" ] = first_connector.get( "connectorId" ) if first_connector else None
161 out[ "role_arn" ] = pay.get( "processPaymentRoleArn" )
162 except Exception : # noqa: BLE001 - convenience only; never fatal
163 pass
164 return out
165
166
167 def resolve_manager_arn (explicit: str | None , config_path: Path) -> str | None :
168 """Manager ARN from --flag, else the environment, else config.json, else the CLI deploy record.
169
170 config.json's resources.payment_manager_arn is exactly the value create-instrument
171 (and init-config, when discoverable) persist right after a successful call — the
172 same source of truth resolve_region() now reads for region. Checking it here means
173 a repeat run of new-session from a different directory (no deployed-state.json in
174 reach) still finds the manager ARN the tool itself already saved, instead of
175 failing with "Could not determine the payment manager ARN" right next to a config
176 file that has had the answer the whole time.
177 """
178 if explicit:
179 return explicit
180 env_arn = os.environ.get( "PAYMENT_MANAGER_ARN" )
181 if env_arn:
182 return env_arn
183 config = load_raw_config(config_path)
184 config_arn = (config.get( "resources" ) or {}).get( "payment_manager_arn" )
185 if config_arn:
186 return config_arn
187 return discover_deployed()[ "manager_arn" ]
188
189
190 def resolve_region (explicit: str | None , config_path: Path) -> str | None :
191 """Region from --flag, else the environment, else the config file, else None.
192
193 init-config (and create-instrument, on success) persist the region actually
194 used into resources.region, right alongside the manager ARN it goes with.
195 Preferring that saved value here — instead of a hardcoded default — keeps
196 the PaymentManager client in the same region as the manager ARN it was just
197 told to use. Returning None when nothing is configured lets boto3's own
198 session/profile resolution take over, rather than silently forcing a
199 region the operator never chose.
200 """
201 if explicit:
202 return explicit
203 env_region = os.environ.get( "AWS_REGION" ) or os.environ.get( "AWS_DEFAULT_REGION" )
204 if env_region:
205 return env_region
206 config = load_raw_config(config_path)
207 return (config.get( "resources" ) or {}).get( "region" )
208
209 # USDC contract addresses per network. Pinned here so an operator cannot be
210 # tricked into allowlisting a look-alike token contract by pasting one in.
211 KNOWN_USDC = {
212 "eip155:84532" : "0x036CbD53842c5426634e7929541eC2318f3dCF7e" , # Base Sepolia (testnet)
213 "eip155:8453" : "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" , # Base mainnet
214 }
215
216
217 def _atomic_write_0600 (path: Path, content: str ) -> None :
218 """Write content to path with mode 0600, atomically.
219
220 Atomic replace prevents a reader from seeing a half-written policy, and the
221 temp file is created 0600 in the same directory so the secret-ish content is
222 never briefly world-readable and the rename never crosses filesystems.
223 """
224 path.parent.mkdir( mode =0o 700 , parents = True , exist_ok = True )
225 os.chmod(path.parent, 0o 700 ) # tighten even if the dir already existed
226
227 fd, tmp = tempfile.mkstemp( dir = str (path.parent), prefix = ".config-" , suffix = ".tmp" )
228 try :
229 os.fchmod(fd, 0o 600 )
230 with os.fdopen(fd, "w" ) as fh:
231 fh.write(content)
232 fh.flush()
233 os.fsync(fh.fileno())
234 os.replace(tmp, path)
235 except BaseException :
236 os.unlink(tmp)
237 raise
238 os.chmod(path, 0o 600 )
239
240
241
242 def load_raw_config (path: Path) -> dict :
243 """Read the config for editing. Missing or unreadable -> empty skeleton."""
244 if not path.exists():
245 return { "resources" : {}, "policy" : {}}
246 try :
247 data = json.loads(path.read_text())
248 except json.JSONDecodeError:
249 return { "resources" : {}, "policy" : {}}
250 if not isinstance (data, dict ):
251 return { "resources" : {}, "policy" : {}}
252 # A flat legacy policy file: lift its keys into the policy section.
253 if "policy" not in data and "resources" not in data:
254 return { "resources" : {}, "policy" : data}
255 data.setdefault( "resources" , {})
256 data.setdefault( "policy" , {})
257 return data
258
259
260 def save_config (path: Path, config: dict ) -> None :
261 """Write the config atomically at 0600, preserving key order for readability."""
262 ordered = { "resources" : config.get( "resources" , {}), "policy" : config.get( "policy" , {})}
263 _atomic_write_0600(path, json.dumps(ordered, indent = 2 ) + " \n " )
264
265
266 def update_resources (path: Path, ** values: str | None ) -> None :
267 """Merge resource identifiers into the config without touching the policy."""
268 config = load_raw_config(path)
269 for key, value in values.items():
270 if value:
271 config[ "resources" ][key] = value
272 save_config(path, config)
273
274
275
276 def resolve_user_id (explicit: str | None , config_path: Path) -> str | None :
277 """The single-tenant user id: explicit flag, else whatever init-config generated.
278
279 This skill treats one installation as one payer. The AgentCore Payments API still
280 needs a userId to scope the instrument and session, but there is no reason to make
281 an operator invent one and then retype it identically at every step — mismatching
282 it between create-instrument and new-session produces a session that cannot spend
283 the instrument, which is a confusing failure to debug.
284 """
285 if explicit:
286 return explicit
287 config = load_raw_config(config_path)
288 return (config.get( "resources" ) or {}).get( "user_id" ) or os.environ.get( "PAYMENT_USER_ID" )
289
290
291 def generate_user_id () -> str :
292 """A stable, opaque id for this installation.
293
294 Random rather than derived from the host or login: it ends up in AgentCore
295 Payments API calls and in the wallet's linked accounts, so it should not leak a
296 machine name or a corporate username.
297 """
298 return "agents-pay-" + secrets.token_hex( 6 )
299
300
301 def cmd_init_config (args: argparse.Namespace) -> int :
302 path = admin_config_path(args.path)
303 if path.exists() and not args.force:
304 print ( f "Refusing to overwrite existing policy at { path } (pass --force)." , file = sys.stderr)
305 return 1
306
307 network = args.network
308 asset = KNOWN_USDC .get(network)
309 if asset is None :
310 print (
311 f "Unknown network { network } . Known: { ', ' .join( KNOWN_USDC ) } . "
312 "Add the exact USDC contract to KNOWN_USDC rather than passing one in." ,
313 file = sys.stderr,
314 )
315 return 1
316
317
318 config = load_raw_config(path) if args.force else { "resources" : {}, "policy" : {}}
319 config[ "policy" ] = {
320 "max_per_payment_usd" : args.max_per_payment_usd,
321 "allowed_networks" : [network],
322 "allowed_assets" : {network: [asset]},
323 "allowed_schemes" : [ "exact" ],
324 }
325 if args.allow_any_recipient:
326 config[ "policy" ][ "allow_any_recipient" ] = True
327 else :
328 config[ "policy" ][ "allowed_recipients" ] = list (args.recipient)
329 # Omitted entirely when not pinned, so the agent can browse the open web.
330 if args.origin:
331 config[ "policy" ][ "allowed_origins" ] = list (args.origin)
332 # One installation = one payer, so the user id is generated once here and read
333 # from the config by every later step. Preserved on --force so an existing
334 # instrument and session keep matching.
335 existing_user = (config.get( "resources" ) or {}).get( "user_id" )
336 user_id = args.user_id or existing_user or generate_user_id()
337 config[ "resources" ][ "user_id" ] = user_id
338
339 # Seed resources from the CLI deploy record so later steps have less to fill in.
340 discovered = discover_deployed()
341 if discovered[ "manager_arn" ]:
342 config[ "resources" ].setdefault( "payment_manager_arn" , discovered[ "manager_arn" ])
343 if args.region:
344 config[ "resources" ].setdefault( "region" , args.region)
345
346 save_config(path, config)
347 print ( f "Wrote config to { path } (mode 0600, in a 0700 directory)" )
348 print ( f "Payer identity: { user_id } "
349 + ( " (generated — single tenant, no need to pass it again)"
350 if not args.user_id and not existing_user else "" ))
351 print (json.dumps({ "resources" : config[ "resources" ], "policy" : config[ "policy" ]}, indent = 2 ))
352 print (
353 " \n The policy section authorizes payments made through the sanctioned runtime. "
354 "That runtime ignores config-path environment overrides. Keep the config and "
355 "ProcessPayment credentials outside any unrestricted same-user shell."
356 )
357 print (
358 " \n Two ceilings, both needed: max_per_payment_usd bounds ONE transaction; the \n "
359 "session budget (set later by new-session) bounds CUMULATIVE spend. Without a \n "
360 "per-payment cap, one hostile challenge for the whole remaining balance would \n "
361 "drain the session in a single payment."
362 )
363 if args.allow_any_recipient:
364 print (
365 " \n WARNING: allow_any_recipient is enabled. The publisher may choose the \n "
366 "payment beneficiary. Network, asset, scheme, origin, per-payment, and \n "
367 "cumulative session limits remain enforced."
368 )
369 else :
370 print (
371 " \n Approved recipients are enforced in trusted code. A challenge whose payTo is \n "
372 "not in allowed_recipients is refused before ProcessPayment is called."
373 )
374 if not args.origin:
375 print (
376 " \n No allowed_origins set: the agent may fetch ANY public HTTPS site. The \n "
377 "SSRF protections still apply (HTTPS only, internal ranges refused, pinned \n "
378 "address, no redirects, bounded body). Pass --origin to pin a merchant set."
379 )
380 return 0
381
382
383 def cmd_show_config (args: argparse.Namespace) -> int :
384 path = admin_config_path(args.path)
385 if not path.exists():
386 print ( f "No config at { path } . Payments are refused until one exists." )
387 print (
388 "Create it with: agents_pay_admin.py init-config "
389 "--max-per-payment-usd 0.05 --recipient <payTo> "
390 "(or explicitly use --allow-any-recipient)"
391 )
392 return 1
393 st = path.lstat()
394 dir_st = path.parent.lstat()
395 print ( f "Config : { path } " )
396 print ( f "File mode : { stat.filemode(st.st_mode) } "
397 f " { 'OK' if not (st.st_mode & 0o 077 ) else '*** TOO OPEN — chmod 600 ***' } " )
398 print ( f "Dir mode : { stat.filemode(dir_st.st_mode) } "
399 f " { 'OK' if not (dir_st.st_mode & 0o 022 ) else '*** WRITABLE BY OTHERS — chmod 700 ***' } " )
400 print ( f "Owner : uid { st.st_uid } { '(you)' if st.st_uid == os.getuid() else '*** NOT YOU ***' } " )
401 config = load_raw_config(path)
402 print ( " \n --- resources (what to pay WITH; no secrets) ---" )
403 print (json.dumps(config.get( "resources" , {}), indent = 2 ))
404 print ( " \n --- policy (what MAY be paid) ---" )
405 print (json.dumps(config.get( "policy" , {}), indent = 2 ))
406 missing = [k for k in ( "payment_manager_arn" , "payment_instrument_id" , "payment_session_id" , "user_id" )
407 if not config.get( "resources" , {}).get(k)]
408 if missing:
409 print ( f " \n Not yet set: { ', ' .join(missing) } " )
410 print ( "These may also come from the environment, but the config file wins when present." )
411 return 0
412
413
414 def cmd_create_instrument (args: argparse.Namespace) -> int :
415 """Create the per-user wallet and print the delegation and funding steps."""
416 try :
417 from bedrock_agentcore.payments import PaymentManager
418 except ImportError :
419 print (
420 "bedrock-agentcore with payments support is not installed. \n "
421 "Activate the setup virtual environment, then run: \n "
422 " python -m pip install --upgrade 'bedrock-agentcore>=1.19.0'" ,
423 file = sys.stderr,
424 )
425 return 1
426
427 config_path = admin_config_path(args.path)
428 region = resolve_region(args.region, config_path)
429
430 if not _check_aws_credentials(region):
431 return 1
432
433 user_id = resolve_user_id(args.user_id, config_path)
434 if not user_id:
435 print (
436 "No payer identity found. Run init-config first (it generates one), or "
437 "pass --user-id explicitly." ,
438 file = sys.stderr,
439 )
440 return 1
441
442 discovered = discover_deployed()
443 manager_arn = resolve_manager_arn(args.manager_arn, config_path)
444 connector_id = args.connector_id or os.environ.get( "PAYMENT_CONNECTOR_ID" ) or discovered[ "connector_id" ]
445
446 if not manager_arn or not connector_id:
447 checked = ", " .join( str (p) for p in deployed_state_candidates())
448 print (
449 f "Could not find deployed-state.json (checked: { checked } ). \n\n "
450 "This file is created by `agentcore deploy`. To resolve: \n "
451 " \u2022 Run this command from the directory that CONTAINS the agentcore/ folder, OR \n "
452 " \u2022 Run from inside the agentcore/ directory itself, OR \n "
453 " \u2022 Pass --manager-arn and --connector-id explicitly." ,
454 file = sys.stderr,
455 )
456 return 1
457
458 manager = PaymentManager(
459 payment_manager_arn = manager_arn,
460 region_name = region,
461 agent_name = AGENT_NAME ,
462 )
463 instrument = manager.create_payment_instrument(
464 user_id = user_id,
465 payment_connector_id = connector_id,
466 payment_instrument_type = "EMBEDDED_CRYPTO_WALLET" ,
467 payment_instrument_details = {
468 "embeddedCryptoWallet" : {
469 "network" : args.network_family,
470 "linkedAccounts" : [{ "email" : { "emailAddress" : args.email}}],
471 }
472 },
473 )
474 instrument_id = instrument[ "paymentInstrumentId" ]
475 wallet = (instrument.get( "paymentInstrumentDetails" ) or {}).get( "embeddedCryptoWallet" , {})
476 wallet_address = wallet.get( "walletAddress" )
477 redirect_url = wallet.get( "redirectUrl" ) # Coinbase only; absent for Privy
478
479 update_resources(
480 config_path,
481 payment_manager_arn = manager_arn,
482 payment_instrument_id = instrument_id,
483 user_id = user_id,
484 region = region,
485 )
486
487 print ( f "Instrument created : { instrument_id } " )
488 print ( f "Wallet address : { wallet_address } " )
489 print ( f "Recorded in : { config_path } (nothing to copy by hand)" )
490 print ( " \n Two one-time steps remain, both done by the END USER, not the agent:" )
491 if redirect_url:
492 print ( f " 1. Delegation: visit { redirect_url } , sign in, grant access to { wallet_address } " )
493 else :
494 print ( " 1. Delegation: approve via the Privy frontend SDK" )
495 print ( " https://github.com/privy-io/aws-agentcore-sdk" )
496 print ( f " 2. Funding : send testnet USDC to { wallet_address } " )
497 print ( " https://faucet.circle.com/ (Base Sepolia)" )
498 print ( f " \n Then authorize spending: \n { Path( __file__ ).name } new-session --budget 1.00 --expiry-minutes 120" )
499 return 0
500
501
502
503
504 def cmd_new_session (args: argparse.Namespace) -> int :
505 """Create a budget-bounded payment session after explicit human confirmation."""
506 # The TTY gate is checked FIRST, before dependency and argument validation,
507 # so the approval requirement is not order-dependent: a headless caller gets
508 # the same refusal whether or not the SDK is installed or the ARN is set.
509 #
510 # Interactive confirmation on a TTY is the approval artifact. It cannot be
511 # produced by the model, by chat history, or by fetched publisher content.
512 #
513 # There is deliberately NO --yes / non-interactive escape hatch. This script
514 # lives inside the skill directory, so any agent with shell access can run
515 # it; a flag that skips the prompt would hand that agent the power to mint
516 # budget. A TTY means a headless agent cannot satisfy the gate even by
517 # invoking the command directly.
518 if not sys.stdin.isatty():
519 print (
520 "Refusing to create a payment session without an interactive terminal. \n "
521 "Session creation requires a human typing 'approve' at a TTY. There is no \n "
522 "non-interactive mode: that would let an automated caller mint spending budget." ,
523 file = sys.stderr,
524 )
525 return 1
526
527 try :
528 from bedrock_agentcore.payments import PaymentManager
529 except ImportError :
530 print (
531 "bedrock-agentcore with payments support is not installed. \n "
532 "Install a version that provides bedrock_agentcore.payments, e.g.: \n "
533 " python -m pip install --upgrade 'bedrock-agentcore>=1.19.0'" ,
534 file = sys.stderr,
535 )
536 return 1
537
538 config_path = admin_config_path(args.path)
539 region = resolve_region(args.region, config_path)
540
541 if not _check_aws_credentials(region):
542 return 1
543
544 user_id = resolve_user_id(args.user_id, config_path)
545 if not user_id:
546 print (
547 "No payer identity found. Run init-config first (it generates one), or "
548 "pass --user-id explicitly." ,
549 file = sys.stderr,
550 )
551 return 1
552
553 manager_arn = resolve_manager_arn(args.manager_arn, config_path)
554 if not manager_arn:
555 checked = ", " .join( str (p) for p in deployed_state_candidates())
556 print (
557 f "Could not determine the payment manager ARN. \n\n "
558 f "Searched for deployed-state.json at: { checked }\n\n "
559 "This file is created by `agentcore deploy`. To resolve: \n "
560 " \u2022 Run this command from the directory that CONTAINS the agentcore/ folder, OR \n "
561 " \u2022 Run from inside the agentcore/ directory itself, OR \n "
562 " \u2022 Pass --manager-arn explicitly, OR \n "
563 " \u2022 Set PAYMENT_MANAGER_ARN in your environment." ,
564 file = sys.stderr,
565 )
566 return 1
567
568 print ( "About to create a payment session:" )
569 print ( f " manager : { manager_arn } " )
570 print ( f " payer : { user_id } " )
571 print ( f " budget : { args.budget } USD (hard cap for the whole session)" )
572 print ( f " expires in: { args.expiry_minutes } minutes" )
573 if input ( "Type 'approve' to continue: " ).strip() != "approve" :
574 print ( "Aborted. No session created." )
575 return 1
576
577 manager = PaymentManager(
578 payment_manager_arn = manager_arn,
579 region_name = region,
580 agent_name = AGENT_NAME ,
581 )
582 session = manager.create_payment_session(
583 user_id = user_id,
584 expiry_time_in_minutes = args.expiry_minutes,
585 limits = { "maxSpendAmount" : { "value" : str (args.budget), "currency" : "USD" }},
586 )
587 session_id = session[ "paymentSessionId" ]
588 update_resources(config_path, payment_session_id = session_id, user_id = user_id)
589
590 print ( f " \n Payment session created: { session_id } " )
591 print ( f "Recorded in : { config_path } (nothing to copy by hand)" )
592 print (
593 f " \n This session allows { args.budget } USD of CUMULATIVE spend. Each individual \n "
594 "payment is additionally capped by max_per_payment_usd in the policy section — \n "
595 "run show-config to see it. Both bounds apply."
596 )
597 print (
598 " \n When this budget is spent, the agent CANNOT mint another session — by design. \n "
599 "Re-run this command yourself to authorize more spending."
600 )
601 return 0
602
603
604 def _prompt (question: str , default: str = "" ) -> str :
605 """Prompt with an optional default shown in brackets."""
606 suffix = f " [ { default } ]: " if default else ": "
607 answer = input (question + suffix).strip()
608 return answer or default
609
610
611 def parse_positive_decimal (value: str , label: str ) -> Decimal:
612 """Parse a human-entered positive decimal without float rounding."""
613 try :
614 amount = Decimal(value)
615 except InvalidOperation as exc:
616 raise ValueError ( f " { label } must be a decimal number." ) from exc
617 if not amount.is_finite() or amount <= 0 :
618 raise ValueError ( f " { label } must be greater than zero." )
619 return amount
620
621
622 def usd_to_atomic (value: Decimal, decimals: int = USDC_DECIMALS ) -> str :
623 """Convert a decimal stablecoin amount to exact atomic units."""
624 atomic = value * (Decimal( 10 ) ** decimals)
625 if atomic != atomic.to_integral_value():
626 raise ValueError (
627 f "Max per-payment USD supports at most { decimals } decimal places."
628 )
629 return str ( int (atomic))
630
631
632 def format_duration (minutes: int ) -> str :
633 """Render minutes with a compact hours hint for human review."""
634 if minutes % 60 == 0 :
635 hours = minutes // 60
636 return f " { minutes } minutes ( { hours } hour { 's' if hours != 1 else '' } )"
637 return f " { minutes } minutes"
638
639
640 def build_openclaw_config (
641 * ,
642 region: str ,
643 manager_arn: str ,
644 instrument_id: str ,
645 session_id: str ,
646 user_id: str ,
647 network: str ,
648 asset: str ,
649 max_payment_atomic: str ,
650 recipients: list[ str ],
651 allow_any: bool ,
652 origins: list[ str ],
653 return_body: bool ,
654 ) -> dict :
655 """Build the final OpenClaw configuration from validated wizard inputs."""
656 plugin_config = {
657 "region" : region,
658 "paymentManagerArn" : manager_arn,
659 "paymentInstrumentId" : instrument_id,
660 "payment_session_id" : session_id,
661 "userId" : user_id,
662 "networkPreferences" : [network],
663 "allowedAssetsByNetwork" : {network: [asset]},
664 "maxPaymentAmountAtomic" : max_payment_atomic,
665 "returnBody" : return_body,
666 }
667 if allow_any:
668 plugin_config[ "allowAnyRecipient" ] = True
669 else :
670 plugin_config[ "allowedRecipients" ] = recipients
671 if origins:
672 plugin_config[ "allowedOrigins" ] = origins
673
674 return {
675 "plugins" : {
676 "allow" : [ "aws-agents-pay" ],
677 "entries" : {
678 "aws-agents-pay" : {
679 "enabled" : True ,
680 "config" : plugin_config,
681 },
682 },
683 }
684 }
685
686
687 def cmd_setup_openclaw (args: argparse.Namespace) -> int :
688 """Interactive guided setup for OpenClaw — collects inputs once and threads through."""
689 if not sys.stdin.isatty():
690 print ( "setup-openclaw requires an interactive terminal." , file = sys.stderr)
691 return 1
692 project_dir = (
693 Path(args.project_dir).expanduser().resolve()
694 if args.project_dir
695 else None
696 )
697 if project_dir and not project_dir.is_dir():
698 print ( f "AgentCore project directory does not exist: { project_dir } " , file = sys.stderr)
699 return 1
700
701 print ( " \n " + "=" * 60 )
702 print ( " AWS Agents Pay — OpenClaw Setup" )
703 print ( "=" * 60 )
704 print ( " \n This wizard provisions payment resources and generates your" )
705 print ( "OpenClaw plugin configuration. You'll need:" )
706 print ( " • agentcore CLI installed and deployed (agentcore deploy)" )
707 print ( " • bedrock-agentcore Python package (>=1.19.0)" )
708 print ( " • AWS credentials with the ManagementRole" )
709 if project_dir:
710 print ( f " • AgentCore project: { project_dir } " )
711 print ()
712
713 # --- Prerequisites check ---
714 try :
715 from bedrock_agentcore.payments import PaymentManager
716 except ImportError :
717 print (
718 "[FAIL] bedrock_agentcore.payments is not installed. \n "
719 "Run: python -m pip install --upgrade 'bedrock-agentcore>=1.19.0'" ,
720 file = sys.stderr,
721 )
722 return 1
723
724 if not _check_aws_credentials():
725 return 1
726
727 # --- Step 1: User identity ---
728 print ( " \n --- Step 1: User Identity ---" )
729 print ( "A stable userId ties your instrument and session together." )
730 user_id = _prompt( "User ID (blank to auto-generate)" , "" )
731 if not user_id:
732 user_id = generate_user_id()
733 print ( f " Generated: { user_id } " )
734
735 # --- Step 2: Region ---
736 region = _prompt( "AWS region" , "us-east-1" )
737
738 # --- Step 3: Network ---
739 print ( " \n --- Step 2: Network ---" )
740 print ( f "Known networks: { ', ' .join( KNOWN_USDC .keys()) } " )
741 network = _prompt( "CAIP-2 network" , "eip155:84532" )
742 if network not in KNOWN_USDC :
743 print ( f "Unknown network { network } . Known: { ', ' .join( KNOWN_USDC ) } " , file = sys.stderr)
744 return 1
745 asset = KNOWN_USDC [network]
746
747 # --- Step 4: Recipient mode ---
748 print ( " \n --- Step 3: Recipient Mode ---" )
749 print ( " 1. Allowlist specific merchant addresses (recommended)" )
750 print ( " 2. Allow any recipient (high risk — publisher chooses beneficiary)" )
751 mode = _prompt( "Choice" , "1" )
752 recipients: list[ str ] = []
753 allow_any = False
754 if mode == "2" :
755 allow_any = True
756 print ( " ⚠ allow-any-recipient enabled." )
757 else :
758 print ( "Enter merchant wallet addresses (one per line, blank to finish):" )
759 while True :
760 addr = input ( " payTo: " ).strip()
761 if not addr:
762 break
763 recipients.append(addr)
764 if not recipients:
765 print ( "At least one recipient is required." , file = sys.stderr)
766 return 1
767
768 # --- Step 5: Per-payment cap ---
769 print ( " \n --- Step 4: Spend Limits ---" )
770 max_usd_text = _prompt(
771 "Max per-payment USD (for example 0.10, not atomic units)" ,
772 "0.05" ,
773 )
774 try :
775 max_usd = parse_positive_decimal(max_usd_text, "Max per-payment USD" )
776 max_payment_atomic = usd_to_atomic(max_usd)
777 except ValueError as exc:
778 print ( str (exc), file = sys.stderr)
779 return 1
780 print (
781 f " $ { format (max_usd, 'f' ) } USD = { max_payment_atomic } atomic units "
782 f "(USDC, { USDC_DECIMALS } decimals)"
783 )
784 budget_text = _prompt( "Cumulative session budget USD" , "5.00" )
785 expiry_text = _prompt( "Session expiry in minutes (1440 = 24 hours)" , "120" )
786 try :
787 budget = parse_positive_decimal(budget_text, "Session budget USD" )
788 expiry = int (expiry_text)
789 if expiry <= 0 :
790 raise ValueError ( "Expiry minutes must be greater than zero." )
791 if max_usd > budget:
792 raise ValueError (
793 "Max per-payment USD cannot exceed the cumulative session budget. "
794 "Enter decimal USD values, not atomic units."
795 )
796 except ( ValueError , TypeError ) as exc:
797 print ( str (exc), file = sys.stderr)
798 return 1
799
800 # --- Step 6: Origins ---
801 print ( " \n Allowed origins (blank to allow any public HTTPS site):" )
802 origins: list[ str ] = []
803 while True :
804 origin = input ( " origin: " ).strip()
805 if not origin:
806 break
807 origins.append(origin)
808
809 # --- Step 7: Return body ---
810 print ( " \n --- Step 5: Paid Content Return ---" )
811 print (
812 "By default, paid publisher content is withheld from the model's context "
813 "as a security \n control — the response body may contain prompt injection. "
814 "Returning it lets the agent \n read/summarize what it paid for, at that risk."
815 )
816 return_body_answer = _prompt( "Return paid response body to the agent? (y/n)" , "y" )
817 return_body = return_body_answer.strip().lower() not in ( "n" , "no" , "false" , "0" )
818
819 # --- Write config ---
820 config_path = admin_config_path(args.path)
821 config: dict = { "resources" : {}, "policy" : {}}
822 config[ "resources" ][ "user_id" ] = user_id
823 config[ "resources" ][ "region" ] = region
824 config[ "policy" ] = {
825 "max_per_payment_usd" : format (max_usd, "f" ),
826 "allowed_networks" : [network],
827 "allowed_assets" : {network: [asset]},
828 "allowed_schemes" : [ "exact" ],
829 "return_body" : return_body,
830 }
831 if allow_any:
832 config[ "policy" ][ "allow_any_recipient" ] = True
833 else :
834 config[ "policy" ][ "allowed_recipients" ] = recipients
835 if origins:
836 config[ "policy" ][ "allowed_origins" ] = origins
837
838 # Discover manager ARN
839 discovered = discover_deployed(project_dir)
840 manager_arn = discovered[ "manager_arn" ]
841 connector_id = discovered[ "connector_id" ]
842 if not manager_arn:
843 checked = ", " .join( str (p) for p in deployed_state_candidates(project_dir))
844 print (
845 " \n Could not auto-discover payment manager ARN from deployed-state.json. \n "
846 f "Checked: { checked } "
847 )
848 manager_arn = _prompt( "Payment Manager ARN" , "" )
849 if not manager_arn:
850 print ( "Manager ARN is required." , file = sys.stderr)
851 return 1
852 else :
853 print ( f " \n Discovered manager ARN: { manager_arn } " )
854 config[ "resources" ][ "payment_manager_arn" ] = manager_arn
855
856 if not connector_id:
857 connector_id = _prompt( "Payment Connector ID" , "" )
858 if not connector_id:
859 print ( "Connector ID is required." , file = sys.stderr)
860 return 1
861 else :
862 print ( f " Discovered connector ID: { connector_id } " )
863
864 save_config(config_path, config)
865 print ( f " \n ✓ Config written to { config_path } " )
866 print (
867 f " Paid response body will be { 'RETURNED to' if return_body else 'WITHHELD from' } "
868 "the agent (policy.return_body)."
869 )
870
871 # --- Create instrument ---
872 print ( " \n --- Step 6: Create Payment Instrument ---" )
873 email = _prompt( "End-user email (for wallet delegation)" , "" )
874 if not email:
875 print ( "Email is required for instrument creation." , file = sys.stderr)
876 return 1
877
878 network_family = "ETHEREUM" if "eip155" in network else "SOLANA"
879 manager = PaymentManager(
880 payment_manager_arn = manager_arn,
881 region_name = region,
882 agent_name = AGENT_NAME ,
883 )
884 instrument = manager.create_payment_instrument(
885 user_id = user_id,
886 payment_connector_id = connector_id,
887 payment_instrument_type = "EMBEDDED_CRYPTO_WALLET" ,
888 payment_instrument_details = {
889 "embeddedCryptoWallet" : {
890 "network" : network_family,
891 "linkedAccounts" : [{ "email" : { "emailAddress" : email}}],
892 }
893 },
894 )
895 instrument_id = instrument[ "paymentInstrumentId" ]
896 wallet = (instrument.get( "paymentInstrumentDetails" ) or {}).get( "embeddedCryptoWallet" , {})
897 wallet_address = wallet.get( "walletAddress" )
898 redirect_url = wallet.get( "redirectUrl" )
899
900 update_resources(config_path, payment_instrument_id = instrument_id)
901 print ( f " ✓ Instrument created: { instrument_id } " )
902 print ( f " Wallet: { wallet_address } " )
903
904 # --- Delegation + funding ---
905 print ( " \n --- Step 7: Delegate & Fund ---" )
906 if redirect_url:
907 print ( f " 1. Visit: { redirect_url } " )
908 print ( f " Sign in and grant access to { wallet_address } " )
909 else :
910 print ( " 1. Approve via the Privy frontend SDK" )
911 print ( f " 2. Send testnet USDC to { wallet_address } " )
912 print ( " https://faucet.circle.com/ (Base Sepolia)" )
913 print ()
914 input ( "Press Enter when delegation and funding are complete..." )
915
916 # --- Create session ---
917 print ( " \n --- Step 8: Create Payment Session ---" )
918 print ( f " \n About to create session:" )
919 print ( f " Budget: $ { format (budget, 'f' ) } USD cumulative" )
920 print (
921 f " Per-payment cap: $ { format (max_usd, 'f' ) } USD "
922 f "( { max_payment_atomic } atomic units)"
923 )
924 print ( f " Expiry: { format_duration(expiry) } " )
925 print ( f " User: { user_id } " )
926 if input ( " Type 'approve' to continue: " ).strip() != "approve" :
927 print ( "Aborted. No session created." )
928 return 1
929
930 session = manager.create_payment_session(
931 user_id = user_id,
932 expiry_time_in_minutes = expiry,
933 limits = {
934 "maxSpendAmount" : {
935 "value" : format (budget, "f" ),
936 "currency" : "USD" ,
937 }
938 },
939 )
940 session_id = session[ "paymentSessionId" ]
941 update_resources(config_path, payment_session_id = session_id)
942 print ( f " ✓ Session created: { session_id } " )
943
944 # --- Generate OpenClaw config ---
945 print ( " \n " + "=" * 60 )
946 print ( " ✅ Setup complete!" )
947 print ( "=" * 60 )
948 print ( " \n Add this to your OpenClaw config (~/.openclaw/openclaw.json):" )
949 print ()
950 openclaw_config = build_openclaw_config(
951 region = region,
952 manager_arn = manager_arn,
953 instrument_id = instrument_id,
954 session_id = session_id,
955 user_id = user_id,
956 network = network,
957 asset = asset,
958 max_payment_atomic = max_payment_atomic,
959 recipients = recipients,
960 allow_any = allow_any,
961 origins = origins,
962 return_body = return_body,
963 )
964
965 print (json.dumps(openclaw_config, indent = 2 ))
966 print ( " \n Then restart OpenClaw to activate the plugin." )
967 return 0
968
969
970 def cmd_preflight (args: argparse.Namespace) -> int :
971 """Verify runtime wiring and assert no secret-shaped values are exposed."""
972 ok = True
973
974 sys.path.insert( 0 , str (Path( __file__ ).resolve().parent))
975 try :
976 import x402_policy as pol
977
978 policy = pol.load_config(admin_config_path(args.path))
979 print ( f "[ok] policy loaded, max_per_payment_usd= { policy[ 'max_per_payment_usd' ] } " )
980 except Exception as e: # noqa: BLE001
981 print ( f "[FAIL] policy: { e } " )
982 ok = False
983
984 required = ( "PAYMENT_MANAGER_ARN" , "PAYMENT_INSTRUMENT_ID" , "PAYMENT_SESSION_ID" , "PAYMENT_USER_ID" )
985 for var in required:
986 print ( f "[ { 'ok' if os.environ.get(var) else '--' } ] { var }{ '' if os.environ.get(var) else ' not set' } " )
987
988 # If the manager ARN is missing but discoverable, hand the operator the exact
989 # line to run rather than making them dig it out of deployed-state.json.
990 if not os.environ.get( "PAYMENT_MANAGER_ARN" ):
991 discovered = discover_deployed()
992 if discovered[ "manager_arn" ]:
993 state_path = _find_deployed_state()
994 print ( f " \n Found in { state_path } : run this to fix the above ->" )
995 print ( f " export PAYMENT_MANAGER_ARN= { discovered[ 'manager_arn' ] } " )
996 else :
997 print (
998 " \n deployed-state.json not found "
999 f "(checked: { ', ' .join( str (p) for p in deployed_state_candidates()) } )."
1000 )
1001 print ( " Run from the directory that CONTAINS the agentcore/ folder," )
1002 print ( " from inside it, or set the variables by hand." )
1003
1004 # This design never needs provider secrets in the runtime process.
1005 leaked = [
1006 k
1007 for k in os.environ
1008 if any (t in k.upper() for t in ( "CDP_API_KEY_SECRET" , "WALLET_SECRET" , "APP_SECRET" , "AUTHORIZATION_PRIVATE_KEY" ))
1009 ]
1010 if leaked:
1011 print ( f "[FAIL] provider secrets present in this environment: { ', ' .join( sorted (leaked)) } " )
1012 print ( " The runtime must never hold provider credentials; signing happens in AgentCore." )
1013 ok = False
1014 else :
1015 print ( "[ok] no provider secrets in the runtime environment" )
1016
1017 try :
1018 from bedrock_agentcore.payments import PaymentManager # noqa: F401
1019
1020 print ( "[ok] bedrock_agentcore.payments available" )
1021 except ImportError :
1022 print ( "[--] bedrock_agentcore.payments not installed (needed only to settle payments)" )
1023
1024 print ( " \n Preflight " + ( "PASSED" if ok else "FAILED" ))
1025 return 0 if ok else 1
1026
1027
1028 def main () -> int :
1029 ap = argparse.ArgumentParser(
1030 description = "Trusted admin CLI for agents-pay. Run by a human, never by an agent." ,
1031 epilog = "This tool never accepts provider secrets as arguments." ,
1032 )
1033 sub = ap.add_subparsers( dest = "command" , required = True )
1034
1035 p = sub.add_parser( "init-config" , help = "Write the config file: resources + policy (mode 0600)" )
1036 p.add_argument( "--max-per-payment-usd" , default = "0.10" ,
1037 help = "PER-PAYMENT ceiling in USD (default 0.10). Distinct from the "
1038 "session budget, which is cumulative." )
1039 p.add_argument( "--region" , default = None , help = "AWS region to record in the config" )
1040 p.add_argument( "--user-id" , default = None ,
1041 help = "Payer identity. Omit to have one generated (single-tenant default)." )
1042 p.add_argument( "--network" , default = "eip155:84532" , help = "CAIP-2 network (default Base Sepolia testnet)" )
1043 recipient_mode = p.add_mutually_exclusive_group( required = True )
1044 recipient_mode.add_argument(
1045 "--recipient" ,
1046 action = "append" ,
1047 help = "Approved merchant payTo wallet address (repeatable)." ,
1048 )
1049 recipient_mode.add_argument(
1050 "--allow-any-recipient" ,
1051 action = "store_true" ,
1052 help = "Allow any challenge payTo. High risk: the publisher chooses the beneficiary." ,
1053 )
1054 p.add_argument( "--origin" , action = "append" , default = [],
1055 help = "Pin to these https origins (repeatable). Omit to allow the open web." )
1056 p.add_argument( "--path" , default = None , help = "Config path (default ~/.agents-pay/config.json)" )
1057 p.add_argument( "--force" , action = "store_true" , help = "Overwrite an existing policy" )
1058 p.set_defaults( func = cmd_init_config)
1059
1060 p = sub.add_parser( "show-config" , help = "Show the active config and its permissions" )
1061 p.add_argument( "--path" , default = None )
1062 p.set_defaults( func = cmd_show_config)
1063
1064 p = sub.add_parser( "create-instrument" , help = "Create a per-user wallet (instrument)" )
1065 p.add_argument( "--user-id" , default = None ,
1066 help = "Override the payer identity (default: read from the config, which "
1067 "init-config generated). Single-tenant, so rarely needed." )
1068 p.add_argument( "--email" , required = True , help = "End-user email, linked to the wallet for delegation" )
1069 p.add_argument( "--network-family" , default = "ETHEREUM" , help = "ETHEREUM (covers Base) or SOLANA" )
1070 p.add_argument( "--manager-arn" , default = None , help = "Default: read from the CLI deploy record" )
1071 p.add_argument( "--connector-id" , default = None , help = "Default: read from the CLI deploy record" )
1072 p.add_argument( "--region" , default = None )
1073 p.add_argument( "--path" , default = None , help = "Config path (default ~/.agents-pay/config.json)" )
1074 p.set_defaults( func = cmd_create_instrument)
1075
1076 p = sub.add_parser( "new-session" , help = "Create a budget-bounded session (human approval)" )
1077 p.add_argument( "--user-id" , default = None , help = "Override the payer identity (rarely needed)" )
1078 p.add_argument( "--budget" , required = True , help = "Session spend cap in USD" )
1079 p.add_argument( "--expiry-minutes" , type = int , default = 60 , help = "Session lifetime (default 60)" )
1080 p.add_argument( "--manager-arn" , default = None )
1081 p.add_argument( "--region" , default = None )
1082 p.add_argument( "--path" , default = None , help = "Config path (default ~/.agents-pay/config.json)" )
1083 # No --yes flag by design: see cmd_new_session. Approval requires a TTY.
1084 p.set_defaults( func = cmd_new_session)
1085
1086 p = sub.add_parser( "preflight" , help = "Verify wiring and check for exposed secrets" )
1087 p.add_argument( "--path" , default = None )
1088 p.set_defaults( func = cmd_preflight)
1089
1090 p = sub.add_parser( "setup-openclaw" , help = "Interactive guided setup for OpenClaw (all steps in one flow)" )
1091 p.add_argument( "--path" , default = None , help = "Config path (default ~/.agents-pay/config.json)" )
1092 p.add_argument(
1093 "--project-dir" ,
1094 default = None ,
1095 help = (
1096 "AgentCore project directory used to locate deployed-state.json "
1097 "(or set AGENTCORE_PROJECT_DIR)"
1098 ),
1099 )
1100 p.set_defaults( func = cmd_setup_openclaw)
1101
1102 args = ap.parse_args()
1103 return args.func(args)
1104
1105
1106 if __name__ == "__main__" :
1107 sys.exit(main())