strix/strix/interface/cloud/billing.py

510 lines
17 KiB
Python

"""Billing top-up and agent-wallet execution for ``strix cloud``."""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import strix.interface.cloud.http as http # noqa: PLR0402
from strix.interface.cloud.payment_proxy import WalletUpstreamResponse, wallet_payment_bridge
from strix.interface.cloud.render import emit
from strix.interface.terminal_text import sanitize_terminal_text
if TYPE_CHECKING:
import argparse
from rich.console import Console
_MAX_WALLET_DETAIL_CHARS = 2_000
# Keep the wallet client on the exact protocol implementation used by the
# 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(
{
"ALL_PROXY",
"APPDATA",
"COLORTERM",
"COMSPEC",
"FORCE_COLOR",
"HOME",
"HTTPS_PROXY",
"HTTP_PROXY",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LOCALAPPDATA",
"NO_COLOR",
"NO_PROXY",
"PATH",
"PATHEXT",
"SSL_CERT_DIR",
"SSL_CERT_FILE",
"SYSTEMROOT",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
"USERPROFILE",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"all_proxy",
"http_proxy",
"https_proxy",
"no_proxy",
}
)
_AUTHORIZATION_SECRET = re.compile(r"(?i)((?:bearer|payment)\s+)[^\s\"']+")
_LOOPBACK_NO_PROXY = ("127.0.0.1", "localhost", "::1")
@dataclass(frozen=True)
class _WalletClientResult:
process: subprocess.CompletedProcess[str]
upstream_responses: tuple[WalletUpstreamResponse, ...]
def run_topup( # noqa: PLR0911, PLR0912, PLR0915
console: Console,
args: argparse.Namespace,
body: dict[str, Any],
*,
as_json: bool,
token: str | None,
) -> int:
"""Handle the HTTP 402 challenge and optional agent-wallet payment."""
response = http.request("POST", "/billing/topup", token=token, body=body)
if response.status_code != 402:
emit(console, http.check(response), as_json=as_json)
return http.EXIT_OK
challenge = http.parsed(response)
if getattr(args, "no_pay", False):
emit(
console,
{"error": "Payment required", "challenge": challenge},
as_json=as_json,
)
return http.EXIT_PAYMENT
credit_count = body.get("credits")
if not getattr(args, "yes", False):
if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()):
emit(
console,
{
"error": (
"Payment requires explicit approval in non-interactive mode. "
"Review the challenge, then re-run with --yes to authorize payment."
),
"challenge": challenge,
},
as_json=as_json,
)
return http.EXIT_PAYMENT
answer = console.input(f"Buy {credit_count} credit(s) now? [y/N]: ").strip().lower()
if answer not in ("y", "yes"):
console.print("[yellow]Payment cancelled.[/]")
return http.EXIT_PAYMENT
npx = shutil.which("npx")
if npx is None:
message = (
"Payment requires a wallet client. Install Node.js and run the command again, "
"or pay the challenge with an MPP wallet client."
)
if as_json:
emit(
console,
{"error": message, "challenge": challenge},
as_json=True,
)
else:
emit(console, challenge, as_json=False)
console.print(f"[yellow]Payment required.[/] {message}")
return http.EXIT_PAYMENT
payment_method = getattr(args, "payment_method", None) or os.environ.get(
"MPPX_STRIPE_PAYMENT_METHOD"
)
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,
args,
body,
token=token,
payment_method=payment_method,
use_link_wallet=use_link_wallet,
capture_output=as_json,
)
except KeyboardInterrupt:
emit(
console,
{
"error": (
"Payment was interrupted after the wallet started. The outcome is unknown; "
"run `strix cloud billing credits` and check the balance before retrying."
),
"interrupted": True,
"payment_outcome_unknown": True,
},
as_json=as_json,
)
return 130
except OSError:
emit(
console,
{
"error": "Could not start the wallet client securely.",
"challenge": challenge,
},
as_json=as_json,
)
return http.EXIT_PAYMENT
result = wallet_result.process
confirmed_receipt = _confirmed_topup_receipt(wallet_result.upstream_responses)
if confirmed_receipt is not None:
if as_json:
emit(console, confirmed_receipt, as_json=True)
return http.EXIT_OK
if not as_json:
console.print(
"[yellow]The wallet exited without a confirmed receipt. The payment outcome is "
"unknown; run `strix cloud billing credits` before retrying.[/]"
)
return http.EXIT_PAYMENT
stdout = str(getattr(result, "stdout", "") or "").strip()
stderr = str(getattr(result, "stderr", "") or "").strip()
if result.returncode == 0:
try:
receipt = json.loads(stdout)
except (TypeError, ValueError):
emit(
console,
{
"error": (
"The wallet reported success but did not return JSON. Check the credit "
"balance before retrying payment."
),
"detail": _wallet_detail(stdout or stderr or "No wallet output was returned."),
"payment_outcome_unknown": True,
},
as_json=True,
)
return http.EXIT_PAYMENT
if not _valid_topup_receipt(receipt):
emit(
console,
{
"error": (
"The wallet returned an invalid top-up receipt. Check the credit balance "
"before retrying payment."
),
"detail": _wallet_detail(stdout),
"payment_outcome_unknown": True,
},
as_json=True,
)
return http.EXIT_PAYMENT
emit(
console,
{
"error": (
"The wallet returned a receipt, but the Strix billing endpoint did not "
"confirm it. Check the credit balance before retrying payment."
),
"detail": _wallet_detail(stdout),
"payment_outcome_unknown": True,
},
as_json=True,
)
return http.EXIT_PAYMENT
emit(
console,
{
"error": (
"The wallet exited without a confirmed receipt. The payment outcome is unknown; "
"run `strix cloud billing credits` and check the balance before retrying."
),
"detail": _wallet_detail(
stderr or stdout or f"Wallet client exited with status {result.returncode}."
),
"wallet_exit_code": result.returncode,
"payment_outcome_unknown": True,
},
as_json=True,
)
return http.EXIT_PAYMENT
def _run_wallet_client(
npx: str,
args: argparse.Namespace,
body: dict[str, Any],
*,
token: str | None,
payment_method: str | None,
use_link_wallet: bool,
capture_output: bool,
) -> _WalletClientResult:
"""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()
upstream_responses: list[WalletUpstreamResponse] = []
with tempfile.TemporaryDirectory(prefix="strix-wallet-") as wallet_cwd:
wallet_root = Path(wallet_cwd)
user_config = wallet_root / "user.npmrc"
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),
workspace_id=http.expected_workspace_id(token_override=token is not None),
expected_body=body_json.encode(),
timeout=getattr(args, "timeout", None),
response_observer=upstream_responses.append,
) as wallet_url:
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,
capture_output=capture_output,
text=True,
env=wallet_env,
cwd=wallet_root,
)
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(("LINK_", "MPPX_"))
}
for name in ("NO_PROXY", "no_proxy"):
entries = [entry.strip() for entry in environment.get(name, "").split(",") if entry.strip()]
normalized = {entry.lower().strip("[]") for entry in entries}
entries.extend(host for host in _LOOPBACK_NO_PROXY if host not in normalized)
environment[name] = ",".join(entries)
return environment
def _wallet_detail(value: str) -> str:
"""Bound and redact third-party wallet diagnostics before returning JSON."""
redacted = _AUTHORIZATION_SECRET.sub(r"\1[redacted]", sanitize_terminal_text(value))
if len(redacted) <= _MAX_WALLET_DETAIL_CHARS:
return redacted
return redacted[: _MAX_WALLET_DETAIL_CHARS - 1] + "…"
def _valid_topup_receipt(value: Any) -> bool:
"""Require the documented success shape before reporting a paid top-up."""
if not isinstance(value, dict):
return False
fields = cast("dict[str, Any]", value)
credits_granted = fields.get("credits_granted")
balance = fields.get("balance")
return (
isinstance(credits_granted, int)
and not isinstance(credits_granted, bool)
and credits_granted >= 0
and isinstance(fields.get("duplicate"), bool)
and isinstance(fields.get("reference"), str)
and bool(fields["reference"])
and isinstance(balance, int)
and not isinstance(balance, bool)
and balance >= 0
)
def _confirmed_topup_receipt(
responses: tuple[WalletUpstreamResponse, ...],
) -> dict[str, Any] | None:
"""Return a receipt only when the trusted bridge observed its successful response."""
for response in reversed(responses):
if not 200 <= response.status_code < 300:
continue
try:
receipt = json.loads(response.body)
except (TypeError, ValueError):
continue
if _valid_topup_receipt(receipt):
return cast("dict[str, Any]", receipt)
return None