diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 14ff6f52..246aa882 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -96,7 +96,7 @@ Non-Enterprise scans consume org credits. Enterprise engagements are plan-includ strix cloud credits ``` -When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the `mppx` client when Node.js is available — the user approves the spend in their agent wallet, for example the [Link Agent Wallet](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance. +When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the Stripe Link wallet client when Node.js is available — the user approves the spend in the [Link app](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance. A default-tier source-only code review currently starts at 60 credits. Source uploads are not free: they launch an ordinary `code_review` and use the same deterministic scope estimator. The service checks the full balance before launch, reserves credits atomically only after validation succeeds, and does not create or charge a rejected scan. Retests and Enterprise scans are exempt. @@ -105,7 +105,7 @@ strix cloud billing topup --credits 20 --yes # explicit approval; skips the TT strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying ``` -The default payment path is the Stripe agent wallet. Tell the user to set it up one time at [link.com/agents](https://link.com/agents). After setup, the user approves each payment in the Link app, and no keys or variables are necessary. +The default payment path is the Stripe Link wallet. When no wallet is connected, an interactive `strix cloud billing topup` starts the Link sign-in for the user and prints the verification link. The user approves the connection one time in the Link app, and then approves each payment there. No keys or variables are necessary. In a non-interactive process, the command stops and tells the user to connect the wallet at [link.com/agents](https://link.com/agents) or to use the hosted checkout link. In a non-interactive agent or CI process, payment never proceeds unless the command includes `--yes`. Show the challenge or estimated spend to the user and obtain approval before adding it. `--no-pay` always stops after printing the challenge. diff --git a/strix/interface/cloud/billing.py b/strix/interface/cloud/billing.py index 995c0c75..2639112e 100644 --- a/strix/interface/cloud/billing.py +++ b/strix/interface/cloud/billing.py @@ -30,6 +30,12 @@ _MAX_WALLET_DETAIL_CHARS = 2_000 # platform. This version is also old enough to remain installable in npm # environments that apply a short package-publication safety window. _MPPX_PACKAGE = "mppx@0.8.17" +# Stripe's own wallet client. It runs the complete challenge flow: it creates a +# spend request, waits for the person to approve it in the Link app, and retries +# the payment with the approved credential. +_LINK_CLI_PACKAGE = "@stripe/link-cli@0.13.1" +_LINK_CLI_CLIENT_NAME = "Strix CLI" +_LINK_LOGIN_TIMEOUT_S = 300 _NPM_REGISTRY = "https://registry.npmjs.org" _WALLET_ENV_NAMES = frozenset( { @@ -58,6 +64,8 @@ _WALLET_ENV_NAMES = frozenset( "TMPDIR", "USERPROFILE", "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", "all_proxy", "http_proxy", "https_proxy", @@ -74,7 +82,7 @@ class _WalletClientResult: upstream_responses: tuple[WalletUpstreamResponse, ...] -def run_topup( # noqa: PLR0911, PLR0912 +def run_topup( # noqa: PLR0911, PLR0912, PLR0915 console: Console, args: argparse.Namespace, body: dict[str, Any], @@ -137,17 +145,16 @@ def run_topup( # noqa: PLR0911, PLR0912 payment_method = getattr(args, "payment_method", None) or os.environ.get( "MPPX_STRIPE_PAYMENT_METHOD" ) - if not payment_method and ( - not as_json - and not os.environ.get("MPPX_ACCOUNT") - and not os.environ.get("MPPX_STRIPE_SECRET_KEY") - ): - console.print( - "[dim]Tip: payments need a wallet. Set up a Stripe agent wallet at " - "https://link.com/agents, and the user approves each payment in the Link app. " - "If the user does not want a wallet, run " - "`strix cloud billing subscribe --plan strix_top_up` for a hosted checkout link.[/]" - ) + use_link_wallet = payment_method is None and not _mppx_wallet_configured() + if use_link_wallet: + setup_error = _prepare_link_wallet(console, npx, as_json=as_json) + if setup_error is not None: + emit( + console, + {"error": setup_error, "challenge": challenge}, + as_json=as_json, + ) + return http.EXIT_PAYMENT try: wallet_result = _run_wallet_client( npx, @@ -155,6 +162,7 @@ def run_topup( # noqa: PLR0911, PLR0912 body, token=token, payment_method=payment_method, + use_link_wallet=use_link_wallet, capture_output=as_json, ) except KeyboardInterrupt: @@ -268,9 +276,10 @@ def _run_wallet_client( *, token: str | None, payment_method: str | None, + use_link_wallet: bool, capture_output: bool, ) -> _WalletClientResult: - """Run mppx through the loopback bridge without exposing the API token to it.""" + """Run the wallet through the loopback bridge without exposing the API token.""" upstream_url = f"{http.app_url()}/api/v1/billing/topup" body_json = json.dumps(body) wallet_env = _wallet_environment() @@ -281,6 +290,7 @@ def _run_wallet_client( global_config = wallet_root / "global.npmrc" user_config.touch(mode=0o600) global_config.touch(mode=0o600) + npx_prefix = _npx_prefix(npx, wallet_root) with wallet_payment_bridge( upstream_url=upstream_url, api_token=http.api_token(token), @@ -289,22 +299,33 @@ def _run_wallet_client( timeout=getattr(args, "timeout", None), response_observer=upstream_responses.append, ) as wallet_url: - command = [ - npx, - "--yes", - f"--registry={_NPM_REGISTRY}", - "--ignore-scripts", - f"--userconfig={user_config}", - f"--globalconfig={global_config}", - f"--cache={wallet_root / 'npm-cache'}", - _MPPX_PACKAGE, - wallet_url, - "--fail", - "-J", - body_json, - ] - if payment_method: - command += ["-M", f"paymentMethod={payment_method}"] + if use_link_wallet: + command = [ + *npx_prefix, + _LINK_CLI_PACKAGE, + "mpp", + "pay", + wallet_url, + "--method", + "POST", + "--data", + body_json, + "--context", + _payment_context(body), + "--format", + "json", + ] + else: + command = [ + *npx_prefix, + _MPPX_PACKAGE, + wallet_url, + "--fail", + "-J", + body_json, + ] + if payment_method: + command += ["-M", f"paymentMethod={payment_method}"] process = subprocess.run( # noqa: S603 command, check=False, @@ -316,12 +337,126 @@ def _run_wallet_client( return _WalletClientResult(process=process, upstream_responses=tuple(upstream_responses)) +def _npx_prefix(npx: str, wallet_root: Path) -> list[str]: + """Install the wallet client from a fixed registry without lifecycle scripts.""" + return [ + npx, + "--yes", + f"--registry={_NPM_REGISTRY}", + "--ignore-scripts", + f"--userconfig={wallet_root / 'user.npmrc'}", + f"--globalconfig={wallet_root / 'global.npmrc'}", + f"--cache={wallet_root / 'npm-cache'}", + ] + + +def _payment_context(body: dict[str, Any]) -> str: + """Describe the purchase for the person who approves it in the Link app.""" + credits_requested = body.get("credits") + return ( + f"Strix scan credits. The Strix command line interface asks to buy " + f"{credits_requested} scan credit(s) for the selected Strix workspace on " + "app.strix.ai. Strix spends the credits on managed penetration test scans " + "that the user starts." + ) + + +def _mppx_wallet_configured() -> bool: + """Report whether the person already configured the mppx wallet client.""" + return bool(os.environ.get("MPPX_ACCOUNT") or os.environ.get("MPPX_STRIPE_SECRET_KEY")) + + +def _run_link_cli( + npx: str, + arguments: list[str], + *, + capture_output: bool, + timeout: float | None = None, +) -> subprocess.CompletedProcess[str]: + """Run one Stripe Link wallet command in an isolated npm environment.""" + with tempfile.TemporaryDirectory(prefix="strix-wallet-") as wallet_cwd: + wallet_root = Path(wallet_cwd) + (wallet_root / "user.npmrc").touch(mode=0o600) + (wallet_root / "global.npmrc").touch(mode=0o600) + return subprocess.run( # noqa: S603 + [*_npx_prefix(npx, wallet_root), _LINK_CLI_PACKAGE, *arguments], + check=False, + capture_output=capture_output, + text=True, + env=_wallet_environment(), + cwd=wallet_root, + timeout=timeout, + ) + + +def _link_wallet_authenticated(npx: str) -> bool: + """Report whether a Link wallet is already connected to this machine.""" + try: + result = _run_link_cli( + npx, + ["auth", "status", "--format", "json"], + capture_output=True, + timeout=_LINK_LOGIN_TIMEOUT_S, + ) + except (OSError, subprocess.SubprocessError): + return False + try: + payload = json.loads(result.stdout or "null") + except (TypeError, ValueError): + return False + if isinstance(payload, list): + payload = payload[0] if payload else None + return bool(isinstance(payload, dict) and payload.get("authenticated")) + + +def _prepare_link_wallet(console: Console, npx: str, *, as_json: bool) -> str | None: + """Connect a Link wallet when none is present. Return an error message on failure.""" + if _link_wallet_authenticated(npx): + return None + + manual_setup = ( + "Payment needs a Stripe Link wallet. Run `strix cloud billing topup` in an " + "interactive terminal to connect one, or set up the wallet at " + "https://link.com/agents. For a browser checkout instead, run " + "`strix cloud billing subscribe --plan strix_top_up`." + ) + if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()): + return manual_setup + + console.print( + "[yellow]No Stripe Link wallet is connected.[/] Strix starts the Link sign-in now. " + "Approve the connection in the Link app, then Strix continues the payment. " + "The user approves every payment in the Link app." + ) + try: + _run_link_cli( + npx, + [ + "auth", + "login", + "--client-name", + _LINK_CLI_CLIENT_NAME, + "--interval", + "3", + "--timeout", + str(_LINK_LOGIN_TIMEOUT_S), + ], + capture_output=False, + timeout=_LINK_LOGIN_TIMEOUT_S + 30, + ) + except (OSError, subprocess.SubprocessError): + return manual_setup + if _link_wallet_authenticated(npx): + return None + return manual_setup + + def _wallet_environment() -> dict[str, str]: """Pass only platform essentials and explicit wallet variables to npm/mppx.""" environment = { name: value for name, value in os.environ.items() - if name in _WALLET_ENV_NAMES or name.startswith("MPPX_") + if name in _WALLET_ENV_NAMES or name.startswith(("LINK_", "MPPX_")) } for name in ("NO_PROXY", "no_proxy"): entries = [entry.strip() for entry in environment.get(name, "").split(",") if entry.strip()] diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py index db7169fc..926ddaae 100644 --- a/strix/interface/cloud/runner.py +++ b/strix/interface/cloud/runner.py @@ -678,8 +678,9 @@ def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentPar default=None, metavar="PM_ID", help=( - "Stripe payment method for the card payment. " - "Defaults to MPPX_STRIPE_PAYMENT_METHOD." + "Pay with the mppx wallet client and this Stripe payment method " + "instead of the Stripe Link wallet. Defaults to " + "MPPX_STRIPE_PAYMENT_METHOD." ), ) if cmd.path == "/scans" and cmd.method == "POST": diff --git a/tests/test_cloud_wallet.py b/tests/test_cloud_wallet.py new file mode 100644 index 00000000..194e4fb9 --- /dev/null +++ b/tests/test_cloud_wallet.py @@ -0,0 +1,75 @@ +"""Tests for the Stripe Link wallet setup path of `strix cloud billing topup`.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING, Any + +from rich.console import Console + +from strix.interface.cloud import billing + + +if TYPE_CHECKING: + import pytest + + +_MIN_LINK_CONTEXT_CHARS = 100 + + +def _completed(stdout: str) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["link-cli"], returncode=0, stdout=stdout, stderr="") + + +def test_payment_context_is_long_enough_for_link_approval() -> None: + context = billing._payment_context({"credits": 5}) + assert len(context) >= _MIN_LINK_CONTEXT_CHARS + assert "5" in context + + +def test_mppx_wallet_configured_follows_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MPPX_ACCOUNT", raising=False) + monkeypatch.delenv("MPPX_STRIPE_SECRET_KEY", raising=False) + assert billing._mppx_wallet_configured() is False + monkeypatch.setenv("MPPX_ACCOUNT", "agent") + assert billing._mppx_wallet_configured() is True + + +def test_link_wallet_authenticated_reads_status_list(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + billing, + "_run_link_cli", + lambda *_args, **_kwargs: _completed('[{"authenticated": true}]'), + ) + assert billing._link_wallet_authenticated("npx") is True + + +def test_link_wallet_authenticated_handles_unusable_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(billing, "_run_link_cli", lambda *_args, **_kwargs: _completed("not json")) + assert billing._link_wallet_authenticated("npx") is False + + +def test_link_wallet_authenticated_handles_launch_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def explode(*_args: Any, **_kwargs: Any) -> subprocess.CompletedProcess[str]: + raise OSError + + monkeypatch.setattr(billing, "_run_link_cli", explode) + assert billing._link_wallet_authenticated("npx") is False + + +def test_prepare_link_wallet_skips_login_when_connected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(billing, "_link_wallet_authenticated", lambda _npx: True) + assert billing._prepare_link_wallet(Console(), "npx", as_json=True) is None + + +def test_prepare_link_wallet_explains_setup_without_a_terminal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(billing, "_link_wallet_authenticated", lambda _npx: False) + message = billing._prepare_link_wallet(Console(), "npx", as_json=True) + assert message is not None + assert "https://link.com/agents" in message