agentcart 0.1.0__py3-none-any.whl
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.
- agentcart/__init__.py +3 -0
- agentcart/__main__.py +5 -0
- agentcart/adapters/__init__.py +33 -0
- agentcart/adapters/base.py +95 -0
- agentcart/adapters/demo.py +73 -0
- agentcart/audit.py +106 -0
- agentcart/browser.py +139 -0
- agentcart/checkout.py +194 -0
- agentcart/cli.py +236 -0
- agentcart/demo_shop.py +139 -0
- agentcart/errors.py +25 -0
- agentcart/extract.py +168 -0
- agentcart/netsafe.py +167 -0
- agentcart/paths.py +59 -0
- agentcart/policy.py +126 -0
- agentcart/sanitize.py +52 -0
- agentcart/server.py +130 -0
- agentcart/vault.py +173 -0
- agentcart-0.1.0.dist-info/METADATA +193 -0
- agentcart-0.1.0.dist-info/RECORD +24 -0
- agentcart-0.1.0.dist-info/WHEEL +4 -0
- agentcart-0.1.0.dist-info/entry_points.txt +2 -0
- agentcart-0.1.0.dist-info/licenses/LICENSE +202 -0
- agentcart-0.1.0.dist-info/licenses/NOTICE +4 -0
agentcart/__init__.py
ADDED
agentcart/__main__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Adapter registry. Adding a store = adding one module here (see docs/ADAPTERS.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import CheckoutError
|
|
6
|
+
from ..netsafe import dev_loopback_enabled, host_matches, registrable_host
|
|
7
|
+
from .base import SiteAdapter
|
|
8
|
+
from .demo import DemoShopAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def all_adapters() -> list[SiteAdapter]:
|
|
12
|
+
adapters: list[SiteAdapter] = []
|
|
13
|
+
if dev_loopback_enabled():
|
|
14
|
+
adapters.append(DemoShopAdapter())
|
|
15
|
+
return adapters
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def for_url(url: str) -> SiteAdapter:
|
|
19
|
+
host = registrable_host(url)
|
|
20
|
+
for a in all_adapters():
|
|
21
|
+
if any(host_matches(host, d) for d in a.domains):
|
|
22
|
+
return a
|
|
23
|
+
supported = ", ".join(d for a in all_adapters() for d in a.domains) or "none yet"
|
|
24
|
+
raise CheckoutError(
|
|
25
|
+
f"no checkout adapter for '{host}' yet (supported: {supported}). "
|
|
26
|
+
"fetch_product still works on any site; contributions welcome, see docs/ADAPTERS.md")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def by_name(name: str) -> SiteAdapter:
|
|
30
|
+
for a in all_adapters():
|
|
31
|
+
if a.name == name:
|
|
32
|
+
return a
|
|
33
|
+
raise CheckoutError(f"unknown site '{name}'")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Site adapter contract. One adapter per store; see docs/ADAPTERS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
|
|
11
|
+
from playwright.async_api import Page
|
|
12
|
+
|
|
13
|
+
from ..errors import CheckoutError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LineItem:
|
|
18
|
+
name: str
|
|
19
|
+
quantity: int
|
|
20
|
+
unit_price: Decimal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Quote:
|
|
25
|
+
items: list[LineItem]
|
|
26
|
+
subtotal: Decimal
|
|
27
|
+
shipping: Decimal
|
|
28
|
+
total: Decimal
|
|
29
|
+
currency: str
|
|
30
|
+
deliver_to: str | None = None
|
|
31
|
+
notes: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def to_public(self) -> dict[str, Any]:
|
|
34
|
+
d = asdict(self)
|
|
35
|
+
for k in ("subtotal", "shipping", "total"):
|
|
36
|
+
d[k] = str(d[k])
|
|
37
|
+
for it in d["items"]:
|
|
38
|
+
it["unit_price"] = str(it["unit_price"])
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class UpiIntent:
|
|
44
|
+
payee_vpa: str
|
|
45
|
+
payee_name: str | None
|
|
46
|
+
amount: Decimal | None
|
|
47
|
+
currency: str | None
|
|
48
|
+
raw: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_upi_intent(uri: str) -> UpiIntent:
|
|
52
|
+
parts = urlsplit(uri)
|
|
53
|
+
if parts.scheme != "upi" or parts.netloc.lower() != "pay":
|
|
54
|
+
raise CheckoutError("payment link is not a upi://pay intent")
|
|
55
|
+
q = {k: v[0] for k, v in parse_qs(parts.query).items()}
|
|
56
|
+
if "pa" not in q or "@" not in q["pa"]:
|
|
57
|
+
raise CheckoutError("UPI intent has no valid payee address")
|
|
58
|
+
amt = Decimal(q["am"]).quantize(Decimal("0.01")) if q.get("am") else None
|
|
59
|
+
return UpiIntent(q["pa"], q.get("pn"), amt, q.get("cu"), uri)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class OrderResult:
|
|
64
|
+
order_id: str | None
|
|
65
|
+
status: str
|
|
66
|
+
total: Decimal | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SiteAdapter(ABC):
|
|
70
|
+
#: short id used for profiles and CLI ('agentcart login <name>')
|
|
71
|
+
name: str
|
|
72
|
+
#: domains this adapter handles (bare, no www.)
|
|
73
|
+
domains: tuple[str, ...]
|
|
74
|
+
#: True only if the adapter implements pay_with_balance against a pre-funded store balance
|
|
75
|
+
supports_autopay: bool = False
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def prepare_checkout(self, page: Page, product_url: str, quantity: int) -> Quote:
|
|
79
|
+
"""Add the product to cart and stop on the payment-method page. Never pays."""
|
|
80
|
+
|
|
81
|
+
async def select_upi(self, page: Page, quote: Quote) -> UpiIntent | None:
|
|
82
|
+
"""Choose UPI as the payment method and return the upi:// intent if the page exposes one.
|
|
83
|
+
|
|
84
|
+
Returning None is fine: the user pays in the visible browser window instead.
|
|
85
|
+
"""
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
async def pay_with_balance(self, page: Page, quote: Quote) -> OrderResult:
|
|
89
|
+
raise CheckoutError(f"{self.name} does not support auto-pay")
|
|
90
|
+
|
|
91
|
+
async def order_status(self, page: Page) -> OrderResult:
|
|
92
|
+
raise CheckoutError(f"{self.name} cannot report order status yet")
|
|
93
|
+
|
|
94
|
+
async def login_url(self) -> str:
|
|
95
|
+
return f"https://{self.domains[0]}/"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Adapter for the bundled demo shop (loopback only). Also the reference implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from playwright.async_api import Page
|
|
9
|
+
|
|
10
|
+
from ..errors import CheckoutError
|
|
11
|
+
from ..sanitize import clean_text, parse_money
|
|
12
|
+
from .base import LineItem, OrderResult, Quote, SiteAdapter, UpiIntent, parse_upi_intent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _money(page: Page, testid: str) -> Decimal:
|
|
16
|
+
v = parse_money(await page.get_by_test_id(testid).inner_text())
|
|
17
|
+
if v is None:
|
|
18
|
+
raise CheckoutError(f"could not read '{testid}' amount from the page")
|
|
19
|
+
return v
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DemoShopAdapter(SiteAdapter):
|
|
23
|
+
name = "demo"
|
|
24
|
+
domains = ("127.0.0.1", "localhost")
|
|
25
|
+
supports_autopay = True
|
|
26
|
+
|
|
27
|
+
async def prepare_checkout(self, page: Page, product_url: str, quantity: int) -> Quote:
|
|
28
|
+
await page.goto(product_url)
|
|
29
|
+
await page.get_by_test_id("qty").fill(str(quantity))
|
|
30
|
+
await page.get_by_test_id("add-to-cart").click()
|
|
31
|
+
await page.wait_for_url("**/checkout")
|
|
32
|
+
qty = int(await page.get_by_test_id("item-qty").inner_text())
|
|
33
|
+
if qty != quantity:
|
|
34
|
+
raise CheckoutError(f"cart has quantity {qty}, expected {quantity}")
|
|
35
|
+
quote = Quote(
|
|
36
|
+
items=[LineItem(clean_text(await page.get_by_test_id("item-name").inner_text(), 200) or "?",
|
|
37
|
+
qty, await _money(page, "item-price"))],
|
|
38
|
+
subtotal=await _money(page, "subtotal"),
|
|
39
|
+
shipping=await _money(page, "shipping"),
|
|
40
|
+
total=await _money(page, "total"),
|
|
41
|
+
currency="INR",
|
|
42
|
+
deliver_to=clean_text(await page.get_by_test_id("address").inner_text(), 200),
|
|
43
|
+
)
|
|
44
|
+
await page.get_by_test_id("to-payment").click()
|
|
45
|
+
await page.wait_for_url("**/payment")
|
|
46
|
+
due = await _money(page, "due")
|
|
47
|
+
if due != quote.total:
|
|
48
|
+
raise CheckoutError(f"payment page amount ₹{due} differs from cart total ₹{quote.total}")
|
|
49
|
+
return quote
|
|
50
|
+
|
|
51
|
+
async def select_upi(self, page: Page, quote: Quote) -> UpiIntent | None:
|
|
52
|
+
await page.get_by_test_id("method-upi").check()
|
|
53
|
+
href = await page.get_by_test_id("upi-intent").get_attribute("href")
|
|
54
|
+
return parse_upi_intent(href) if href else None
|
|
55
|
+
|
|
56
|
+
async def pay_with_balance(self, page: Page, quote: Quote) -> OrderResult:
|
|
57
|
+
if await _money(page, "due") != quote.total:
|
|
58
|
+
raise CheckoutError("amount changed before payment; aborted")
|
|
59
|
+
await page.get_by_test_id("pay-balance").click()
|
|
60
|
+
await page.wait_for_load_state()
|
|
61
|
+
return await self.order_status(page)
|
|
62
|
+
|
|
63
|
+
async def order_status(self, page: Page) -> OrderResult:
|
|
64
|
+
if "/order/" not in urlsplit(page.url).path:
|
|
65
|
+
if await page.get_by_test_id("error").count():
|
|
66
|
+
return OrderResult(None, "FAILED: " + (await page.get_by_test_id("error").inner_text()),
|
|
67
|
+
None)
|
|
68
|
+
return OrderResult(None, "PENDING", None)
|
|
69
|
+
return OrderResult(
|
|
70
|
+
clean_text(await page.get_by_test_id("order-id").inner_text(), 40),
|
|
71
|
+
clean_text(await page.get_by_test_id("order-status").inner_text(), 40) or "UNKNOWN",
|
|
72
|
+
await _money(page, "order-total"),
|
|
73
|
+
)
|
agentcart/audit.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Append-only, hash-chained audit log of every money-related action.
|
|
2
|
+
|
|
3
|
+
Each line is JSON: {"seq", "ts", "event", "data", "prev", "hash"} where
|
|
4
|
+
hash = sha256(prev || canonical_json(seq, ts, event, data)). The first entry's prev is the vault's
|
|
5
|
+
random genesis value. Any edit, reordering or deletion in the middle breaks the chain; on a broken
|
|
6
|
+
chain auto-pay fails closed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import paths
|
|
23
|
+
from .errors import TamperError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canon(obj: Any) -> bytes:
|
|
27
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextlib.contextmanager
|
|
31
|
+
def _locked(path: Path) -> Iterator[None]:
|
|
32
|
+
lock = path.with_suffix(".lock")
|
|
33
|
+
fd = os.open(lock, os.O_RDWR | os.O_CREAT | paths.O_BINARY, 0o600)
|
|
34
|
+
try:
|
|
35
|
+
if sys.platform == "win32": # pragma: no cover
|
|
36
|
+
import msvcrt
|
|
37
|
+
|
|
38
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
39
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
44
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
45
|
+
else:
|
|
46
|
+
import fcntl
|
|
47
|
+
|
|
48
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
49
|
+
yield # released on close
|
|
50
|
+
finally:
|
|
51
|
+
os.close(fd)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_verified(genesis: str, path: Path | None = None) -> list[dict[str, Any]]:
|
|
55
|
+
path = path or paths.audit_file()
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return []
|
|
58
|
+
entries: list[dict[str, Any]] = []
|
|
59
|
+
prev = genesis
|
|
60
|
+
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
61
|
+
if not line.strip():
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
e = json.loads(line)
|
|
65
|
+
except ValueError as err:
|
|
66
|
+
raise TamperError(f"audit log line {n} is not valid JSON") from err
|
|
67
|
+
body = {k: e.get(k) for k in ("seq", "ts", "event", "data")}
|
|
68
|
+
expect = hashlib.sha256(prev.encode() + _canon(body)).hexdigest()
|
|
69
|
+
if e.get("prev") != prev or e.get("hash") != expect or e.get("seq") != len(entries):
|
|
70
|
+
raise TamperError(f"audit log chain broken at line {n}")
|
|
71
|
+
entries.append(e)
|
|
72
|
+
prev = e["hash"]
|
|
73
|
+
return entries
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def append(genesis: str, event: str, data: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
|
77
|
+
path = path or paths.audit_file()
|
|
78
|
+
with _locked(path):
|
|
79
|
+
entries = read_verified(genesis, path)
|
|
80
|
+
prev = entries[-1]["hash"] if entries else genesis
|
|
81
|
+
body = {"seq": len(entries), "ts": datetime.now(UTC).isoformat(), "event": event,
|
|
82
|
+
"data": json.loads(_canon(data))}
|
|
83
|
+
entry = {**body, "prev": prev, "hash": hashlib.sha256(prev.encode() + _canon(body)).hexdigest()}
|
|
84
|
+
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | paths.O_BINARY, 0o600)
|
|
85
|
+
try:
|
|
86
|
+
os.write(fd, _canon(entry) + b"\n")
|
|
87
|
+
os.fsync(fd)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
return entry
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def autopay_spend(entries: list[dict[str, Any]], now: datetime | None = None) -> tuple[Decimal, int]:
|
|
94
|
+
"""Return (INR spent via auto-pay this calendar month, auto-pay orders in the last 24h)."""
|
|
95
|
+
now = now or datetime.now(UTC)
|
|
96
|
+
month_total, day_count = Decimal("0"), 0
|
|
97
|
+
for e in entries:
|
|
98
|
+
if e["event"] not in ("autopay_attempt",):
|
|
99
|
+
continue
|
|
100
|
+
ts = datetime.fromisoformat(e["ts"])
|
|
101
|
+
amount = Decimal(str(e["data"]["amount"]))
|
|
102
|
+
if (ts.year, ts.month) == (now.year, now.month):
|
|
103
|
+
month_total += amount
|
|
104
|
+
if (now - ts).total_seconds() < 86400:
|
|
105
|
+
day_count += 1
|
|
106
|
+
return month_total, day_count
|
agentcart/browser.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Hardened Playwright browser sessions.
|
|
2
|
+
|
|
3
|
+
* one persistent profile per site, stored on-device (0700) -- you log in once yourself with
|
|
4
|
+
'agentcart login <site>'; agentcart never sees or stores your password
|
|
5
|
+
* headed (visible) by default so you can always watch what the agent does
|
|
6
|
+
* main-frame navigations outside the adapter's own domains (plus known payment gateways) are blocked
|
|
7
|
+
* downloads, geolocation, camera, mic, notifications and clipboard are denied
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import ipaddress
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from playwright.async_api import BrowserContext, Page, Playwright, Route, async_playwright
|
|
21
|
+
|
|
22
|
+
from . import paths
|
|
23
|
+
from .netsafe import dev_loopback_enabled, host_matches
|
|
24
|
+
|
|
25
|
+
# Hosted payment pages Indian merchants commonly redirect to. Navigation to these is allowed.
|
|
26
|
+
PAYMENT_GATEWAYS = (
|
|
27
|
+
"razorpay.com", "payu.in", "payumoney.com", "cashfree.com", "ccavenue.com", "paytm.in",
|
|
28
|
+
"paytm.com", "phonepe.com", "juspay.in", "billdesk.com", "stripe.com", "easebuzz.in",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _executable() -> str | None:
|
|
33
|
+
explicit = os.environ.get("AGENTCART_CHROMIUM")
|
|
34
|
+
if explicit:
|
|
35
|
+
return explicit
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_MISSING_BROWSER = "Executable doesn't exist"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _install_chromium() -> None:
|
|
43
|
+
"""One-time download of Playwright's Chromium (fixed command, no user-controlled input)."""
|
|
44
|
+
print("agentcart: first run, downloading Chromium (one time, ~150 MB)...", file=sys.stderr)
|
|
45
|
+
proc = await asyncio.create_subprocess_exec(
|
|
46
|
+
sys.executable, "-m", "playwright", "install", "chromium",
|
|
47
|
+
stdout=sys.stderr, stderr=sys.stderr,
|
|
48
|
+
)
|
|
49
|
+
if await proc.wait() != 0:
|
|
50
|
+
raise RuntimeError("Chromium download failed; run 'uvx playwright install chromium' manually")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Session:
|
|
55
|
+
context: BrowserContext
|
|
56
|
+
page: Page
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BrowserManager:
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._pw: Playwright | None = None
|
|
62
|
+
|
|
63
|
+
async def _playwright(self) -> Playwright:
|
|
64
|
+
if self._pw is None:
|
|
65
|
+
self._pw = await async_playwright().start()
|
|
66
|
+
return self._pw
|
|
67
|
+
|
|
68
|
+
async def open(self, site: str, domains: tuple[str, ...], headless: bool | None = None) -> Session:
|
|
69
|
+
pw = await self._playwright()
|
|
70
|
+
if headless is None:
|
|
71
|
+
headless = os.environ.get("AGENTCART_HEADLESS") == "1"
|
|
72
|
+
profile = paths.ensure_private_dir(paths.profiles_dir() / site)
|
|
73
|
+
kwargs: dict[str, Any] = {
|
|
74
|
+
"headless": headless,
|
|
75
|
+
"accept_downloads": False,
|
|
76
|
+
"permissions": [],
|
|
77
|
+
"service_workers": "block",
|
|
78
|
+
"locale": "en-IN",
|
|
79
|
+
"timezone_id": "Asia/Kolkata",
|
|
80
|
+
}
|
|
81
|
+
exe = _executable()
|
|
82
|
+
if exe:
|
|
83
|
+
kwargs["executable_path"] = exe
|
|
84
|
+
try:
|
|
85
|
+
ctx = await pw.chromium.launch_persistent_context(str(profile), **kwargs)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
if exe or _MISSING_BROWSER not in str(e):
|
|
88
|
+
raise
|
|
89
|
+
await _install_chromium()
|
|
90
|
+
ctx = await pw.chromium.launch_persistent_context(str(profile), **kwargs)
|
|
91
|
+
allowed = tuple(domains) + PAYMENT_GATEWAYS
|
|
92
|
+
|
|
93
|
+
async def guard(route: Route) -> None:
|
|
94
|
+
req = route.request
|
|
95
|
+
if req.is_navigation_request():
|
|
96
|
+
main = req.frame.parent_frame is None
|
|
97
|
+
# Top-level page: only the store + payment gateways. Iframes (captcha, 3-D Secure,
|
|
98
|
+
# gateway widgets) may be any https origin, never plain http or private hosts.
|
|
99
|
+
ok = (_navigation_allowed(req.url, allowed) if main
|
|
100
|
+
else _navigation_allowed(req.url, None))
|
|
101
|
+
if not ok:
|
|
102
|
+
await route.abort("blockedbyclient")
|
|
103
|
+
return
|
|
104
|
+
await route.continue_()
|
|
105
|
+
|
|
106
|
+
await ctx.route("**/*", guard)
|
|
107
|
+
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
108
|
+
page.set_default_timeout(20_000)
|
|
109
|
+
return Session(ctx, page)
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
if self._pw is not None:
|
|
113
|
+
await self._pw.stop()
|
|
114
|
+
self._pw = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _navigation_allowed(url: str, allowed: tuple[str, ...] | None) -> bool:
|
|
118
|
+
"""allowed=None means 'any public https origin' (used for iframes)."""
|
|
119
|
+
parts = urlsplit(url)
|
|
120
|
+
if parts.scheme in ("about", "data") and url.startswith(("about:blank", "data:text/html")):
|
|
121
|
+
return url == "about:blank"
|
|
122
|
+
host = (parts.hostname or "").lower()
|
|
123
|
+
if dev_loopback_enabled():
|
|
124
|
+
try:
|
|
125
|
+
if ipaddress.ip_address(host).is_loopback:
|
|
126
|
+
return parts.scheme in ("http", "https")
|
|
127
|
+
except ValueError:
|
|
128
|
+
if host == "localhost":
|
|
129
|
+
return True
|
|
130
|
+
if parts.scheme != "https" or _is_private_literal(host) or host == "localhost":
|
|
131
|
+
return False
|
|
132
|
+
return allowed is None or any(host_matches(host, d) for d in allowed)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_private_literal(host: str) -> bool:
|
|
136
|
+
try:
|
|
137
|
+
return not ipaddress.ip_address(host.strip("[]")).is_global
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
agentcart/checkout.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Checkout orchestration shared by the MCP server and the CLI. All security decisions live here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import segno
|
|
14
|
+
|
|
15
|
+
from . import audit, paths, policy, vault
|
|
16
|
+
from .adapters import for_url
|
|
17
|
+
from .adapters.base import OrderResult, Quote, SiteAdapter, UpiIntent
|
|
18
|
+
from .browser import BrowserManager, Session
|
|
19
|
+
from .errors import AgentCartError, CheckoutError, PolicyViolation
|
|
20
|
+
from .extract import Product, extract_product
|
|
21
|
+
from .netsafe import fetch_html, registrable_host, validate_url_syntax
|
|
22
|
+
from .sanitize import UNTRUSTED_NOTE
|
|
23
|
+
|
|
24
|
+
MAX_SESSIONS = 3
|
|
25
|
+
SESSION_TTL_S = 20 * 60
|
|
26
|
+
MAX_QTY = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def fetch_product(url: str) -> dict[str, Any]:
|
|
30
|
+
res = await fetch_html(url)
|
|
31
|
+
product = extract_product(res.text, res.final_url)
|
|
32
|
+
return {"source_url": res.final_url, "note": UNTRUSTED_NOTE, "untrusted": product.to_public(),
|
|
33
|
+
"found": bool(product.name or product.price)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Checkout:
|
|
38
|
+
id: str
|
|
39
|
+
adapter: SiteAdapter
|
|
40
|
+
session: Session
|
|
41
|
+
host: str
|
|
42
|
+
product_url: str
|
|
43
|
+
quote: Quote
|
|
44
|
+
listed_price: Decimal | None
|
|
45
|
+
upi: UpiIntent | None
|
|
46
|
+
created: float = field(default_factory=time.monotonic)
|
|
47
|
+
state: str = "awaiting_payment"
|
|
48
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _audit(event: str, data: dict[str, Any]) -> None:
|
|
52
|
+
"""Best-effort log when no vault exists yet (Flow 1 works without a vault); strict otherwise."""
|
|
53
|
+
if not vault.exists():
|
|
54
|
+
return
|
|
55
|
+
pol = policy.load()
|
|
56
|
+
if pol.genesis:
|
|
57
|
+
audit.append(pol.genesis, event, data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CheckoutManager:
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.browser = BrowserManager()
|
|
63
|
+
self.checkouts: dict[str, Checkout] = {}
|
|
64
|
+
|
|
65
|
+
async def _reap(self) -> None:
|
|
66
|
+
now = time.monotonic()
|
|
67
|
+
for cid, c in list(self.checkouts.items()):
|
|
68
|
+
if now - c.created > SESSION_TTL_S and c.state != "paying":
|
|
69
|
+
await self._close(cid)
|
|
70
|
+
|
|
71
|
+
async def _close(self, cid: str) -> None:
|
|
72
|
+
c = self.checkouts.pop(cid, None)
|
|
73
|
+
if c:
|
|
74
|
+
with contextlib.suppress(Exception): # closing a dead browser is fine
|
|
75
|
+
await c.session.context.close()
|
|
76
|
+
|
|
77
|
+
def _get(self, cid: str) -> Checkout:
|
|
78
|
+
c = self.checkouts.get(cid)
|
|
79
|
+
if not c:
|
|
80
|
+
raise CheckoutError("unknown or expired checkout_id; start a new checkout")
|
|
81
|
+
return c
|
|
82
|
+
|
|
83
|
+
async def start(self, url: str, quantity: int) -> dict[str, Any]:
|
|
84
|
+
validate_url_syntax(url)
|
|
85
|
+
if not 1 <= quantity <= MAX_QTY:
|
|
86
|
+
raise CheckoutError(f"quantity must be between 1 and {MAX_QTY}")
|
|
87
|
+
await self._reap()
|
|
88
|
+
if len(self.checkouts) >= MAX_SESSIONS:
|
|
89
|
+
raise CheckoutError("too many open checkouts; cancel one first")
|
|
90
|
+
adapter = for_url(url)
|
|
91
|
+
listed: Product | None = None
|
|
92
|
+
try:
|
|
93
|
+
res = await fetch_html(url)
|
|
94
|
+
listed = extract_product(res.text, res.final_url)
|
|
95
|
+
except AgentCartError:
|
|
96
|
+
listed = None
|
|
97
|
+
if any(c.adapter.name == adapter.name for c in self.checkouts.values()):
|
|
98
|
+
raise CheckoutError(f"a checkout on {adapter.name} is already open; finish or cancel it")
|
|
99
|
+
session = await self.browser.open(adapter.name, adapter.domains)
|
|
100
|
+
try:
|
|
101
|
+
quote = await adapter.prepare_checkout(session.page, url, quantity)
|
|
102
|
+
upi = await adapter.select_upi(session.page, quote)
|
|
103
|
+
except Exception:
|
|
104
|
+
await session.context.close()
|
|
105
|
+
raise
|
|
106
|
+
warnings: list[str] = []
|
|
107
|
+
listed_price = listed.price if listed else None
|
|
108
|
+
if listed_price is not None and quote.items and quote.items[0].unit_price > listed_price:
|
|
109
|
+
warnings.append(f"cart price ₹{quote.items[0].unit_price} is higher than listed ₹{listed_price}")
|
|
110
|
+
if upi:
|
|
111
|
+
if upi.amount != quote.total:
|
|
112
|
+
warnings.append(f"UPI request amount {upi.amount} does not match total {quote.total} — "
|
|
113
|
+
"do NOT pay")
|
|
114
|
+
if (upi.currency or "INR") != "INR":
|
|
115
|
+
warnings.append("UPI request is not in INR — do NOT pay")
|
|
116
|
+
cid = "co_" + secrets.token_urlsafe(9)
|
|
117
|
+
self.checkouts[cid] = Checkout(cid, adapter, session, registrable_host(url), url, quote,
|
|
118
|
+
listed_price, upi)
|
|
119
|
+
_audit("checkout_started", {"id": cid, "site": adapter.name, "total": str(quote.total)})
|
|
120
|
+
out: dict[str, Any] = {
|
|
121
|
+
"checkout_id": cid,
|
|
122
|
+
"site": adapter.name,
|
|
123
|
+
"quote": quote.to_public(),
|
|
124
|
+
"warnings": warnings,
|
|
125
|
+
"how_to_pay": (
|
|
126
|
+
"Flow 1 (default): the browser window is open on the payment page. Scan the UPI QR "
|
|
127
|
+
"there (or the QR below) with your UPI app, CHECK the payee name and amount, and "
|
|
128
|
+
"enter your UPI PIN on your phone. agentcart never sees your PIN."),
|
|
129
|
+
"autopay_available": adapter.supports_autopay,
|
|
130
|
+
}
|
|
131
|
+
if upi:
|
|
132
|
+
qr_path = paths.ensure_private_dir(paths.home() / "qr") / f"{cid}.png"
|
|
133
|
+
qr = segno.make(upi.raw, error="m")
|
|
134
|
+
qr.save(str(qr_path), scale=8)
|
|
135
|
+
out["upi"] = {"payee_vpa": upi.payee_vpa, "payee_name": upi.payee_name,
|
|
136
|
+
"amount": str(upi.amount) if upi.amount is not None else None,
|
|
137
|
+
"qr_png": str(qr_path)}
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
async def autopay(self, cid: str) -> dict[str, Any]:
|
|
141
|
+
"""Flow 2. Every rule is enforced here; any failure falls back to Flow 1."""
|
|
142
|
+
c = self._get(cid)
|
|
143
|
+
async with c.lock:
|
|
144
|
+
if c.state != "awaiting_payment":
|
|
145
|
+
raise CheckoutError(f"checkout is '{c.state}', cannot pay again")
|
|
146
|
+
if not c.adapter.supports_autopay:
|
|
147
|
+
raise PolicyViolation(f"{c.adapter.name} does not support auto-pay; pay with UPI instead")
|
|
148
|
+
pol = policy.load() # verifies signature; TamperError fails closed
|
|
149
|
+
listed_total = (c.listed_price * sum(i.quantity for i in c.quote.items) + c.quote.shipping
|
|
150
|
+
if c.listed_price is not None else Decimal(0))
|
|
151
|
+
d = policy.evaluate_autopay(pol, c.host, c.quote.total, c.quote.currency, listed_total)
|
|
152
|
+
if not d.allowed:
|
|
153
|
+
if pol.genesis:
|
|
154
|
+
audit.append(pol.genesis, "autopay_denied",
|
|
155
|
+
{"id": cid, "amount": str(c.quote.total), "reasons": d.reasons})
|
|
156
|
+
raise PolicyViolation("auto-pay blocked: " + "; ".join(d.reasons) +
|
|
157
|
+
". You can still pay manually with UPI (Flow 1).")
|
|
158
|
+
# Log BEFORE paying so a crash mid-payment still counts against the caps.
|
|
159
|
+
audit.append(pol.genesis, "autopay_attempt", {"id": cid, "site": c.adapter.name,
|
|
160
|
+
"amount": str(c.quote.total)})
|
|
161
|
+
c.state = "paying"
|
|
162
|
+
try:
|
|
163
|
+
result: OrderResult = await c.adapter.pay_with_balance(c.session.page, c.quote)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
c.state = "unknown"
|
|
166
|
+
audit.append(pol.genesis, "autopay_error", {"id": cid, "error": type(e).__name__})
|
|
167
|
+
raise CheckoutError("payment step failed; check the browser window and your store "
|
|
168
|
+
"account before retrying") from e
|
|
169
|
+
c.state = "paid" if result.order_id else "unknown"
|
|
170
|
+
audit.append(pol.genesis, "autopay_result", {"id": cid, "order_id": result.order_id,
|
|
171
|
+
"status": result.status})
|
|
172
|
+
return {"checkout_id": cid, "order_id": result.order_id, "status": result.status,
|
|
173
|
+
"paid_inr": str(result.total) if result.total is not None else None}
|
|
174
|
+
|
|
175
|
+
async def status(self, cid: str) -> dict[str, Any]:
|
|
176
|
+
c = self._get(cid)
|
|
177
|
+
r = await c.adapter.order_status(c.session.page)
|
|
178
|
+
if r.order_id and c.state == "awaiting_payment":
|
|
179
|
+
c.state = "paid"
|
|
180
|
+
_audit("manual_payment_confirmed", {"id": cid, "order_id": r.order_id})
|
|
181
|
+
return {"checkout_id": cid, "state": c.state, "order_id": r.order_id, "status": r.status}
|
|
182
|
+
|
|
183
|
+
async def cancel(self, cid: str) -> dict[str, Any]:
|
|
184
|
+
c = self._get(cid)
|
|
185
|
+
if c.state == "paying":
|
|
186
|
+
raise CheckoutError("payment in progress; cannot cancel now")
|
|
187
|
+
await self._close(cid)
|
|
188
|
+
_audit("checkout_cancelled", {"id": cid})
|
|
189
|
+
return {"checkout_id": cid, "state": "cancelled"}
|
|
190
|
+
|
|
191
|
+
async def shutdown(self) -> None:
|
|
192
|
+
for cid in list(self.checkouts):
|
|
193
|
+
await self._close(cid)
|
|
194
|
+
await self.browser.close()
|