diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 261d6a55..4195111a 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -71,6 +71,8 @@ strix cloud billing topup --credits 20 --yes # --yes skips the confirmation pr strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying ``` +Card payments need a payer-side Stripe payment method. Pass it with `--payment-method pm_...` or set `MPPX_STRIPE_PAYMENT_METHOD`, together with the payer key in `MPPX_STRIPE_SECRET_KEY`. A funded `mppx` account (`npx mppx account create`) works for stablecoin payments without those variables. + If no wallet is available, print the challenge with `--no-pay` and ask the user to top up in the dashboard instead. Automatic top-ups (admin): `strix cloud billing auto-topup` shows the setting. Enable it with: diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index 7c400609..7f7300c2 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse import json +import os import re import shutil import subprocess @@ -219,6 +220,15 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar action="store_true", help="Print the payment challenge instead of paying it.", ) + parser.add_argument( + "--payment-method", + default=None, + metavar="PM_ID", + help=( + "Stripe payment method for the card payment, for example pm_card_visa " + "in test mode. Defaults to MPPX_STRIPE_PAYMENT_METHOD." + ), + ) return parser @@ -362,8 +372,17 @@ def _topup( url = f"{http.app_url()}/api/v1/billing/topup" auth_header = f"Authorization: Bearer {http.api_token(token)}" - result = subprocess.run( # noqa: S603 - [npx, "--yes", "mppx", url, "-J", json.dumps(body), "-H", auth_header], - check=False, + command = [npx, "--yes", "mppx", url, "-J", json.dumps(body), "-H", auth_header] + payment_method = getattr(args, "payment_method", None) or os.environ.get( + "MPPX_STRIPE_PAYMENT_METHOD" ) + if payment_method: + command += ["-M", f"paymentMethod={payment_method}"] + elif not os.environ.get("MPPX_ACCOUNT") and not os.environ.get("MPPX_STRIPE_SECRET_KEY"): + console.print( + "[dim]Tip: card payments need a wallet. Pass --payment-method, or set " + "MPPX_STRIPE_SECRET_KEY and MPPX_STRIPE_PAYMENT_METHOD, or create an " + "mppx account first with `npx mppx account create`.[/]" + ) + result = subprocess.run(command, check=False) # noqa: S603 return http.EXIT_OK if result.returncode == 0 else http.EXIT_PAYMENT diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 08501c35..820a30b5 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -4,6 +4,8 @@ from __future__ import annotations import io import json +import shutil +import subprocess import webbrowser from typing import TYPE_CHECKING, Any @@ -300,6 +302,28 @@ def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: assert json.loads(capsys.readouterr().out) == receipt +def test_topup_passes_payment_method_to_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + commands: list[list[str]] = [] + + def fake_run(command: list[str], **_kwargs: Any) -> Any: + commands.append(command) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(subprocess, "run", fake_run) + code = cloud.run_cloud( + ["billing", "topup", "--credits", "5", "--yes", "--payment-method", "pm_card_visa"] + ) + assert code == 0 + assert "-M" in commands[0] + assert "paymentMethod=pm_card_visa" in commands[0] + + def test_render_json_mode_when_not_a_tty() -> None: assert render.json_mode(flag=True) is True # Under pytest, stdout is captured and is not a terminal.