assertify-mcp 0.6.3__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.
assertify/cli.py ADDED
@@ -0,0 +1,120 @@
1
+ """Command line: `assertify serve | login | list | delete`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ import threading
10
+
11
+ from .vault import Vault, VaultError
12
+
13
+
14
+ async def _login(profile: str | None, url: str, domains: list[str], read_only: bool) -> None:
15
+ from .browser import BrowserManager
16
+
17
+ m = BrowserManager()
18
+ try:
19
+ await m.start_login(profile, url, domains, read_only=read_only)
20
+ profile = next(iter(m.logins))
21
+ print(f"A browser window is open at {url}. Log in there (including any 2FA).")
22
+ print("When you're done, close the browser window or press Enter here.")
23
+ closed = asyncio.create_task(m.logins[profile].done.wait())
24
+ enter = asyncio.create_task(_wait_for_enter())
25
+ await asyncio.wait({closed, enter}, return_when=asyncio.FIRST_COMPLETED)
26
+ enter.cancel()
27
+ print(await m.wait_login(profile) if closed.done() else await m.complete_login(profile))
28
+ finally:
29
+ await m.shutdown()
30
+
31
+
32
+ async def _wait_for_enter() -> None:
33
+ """Wait for Enter while still letting a closed window end the command."""
34
+ loop = asyncio.get_running_loop()
35
+ fut = loop.create_future()
36
+
37
+ def done() -> None:
38
+ if not fut.done():
39
+ fut.set_result(None)
40
+
41
+ try:
42
+ loop.add_reader(sys.stdin, lambda: (sys.stdin.readline(), done()))
43
+ except (NotImplementedError, ValueError, OSError):
44
+ # Windows has no add_reader: read in a daemon thread, which can't keep the process alive.
45
+ def read() -> None:
46
+ try:
47
+ sys.stdin.readline()
48
+ except Exception:
49
+ return
50
+ loop.call_soon_threadsafe(done)
51
+
52
+ threading.Thread(target=read, daemon=True).start()
53
+ return await fut
54
+ try:
55
+ await fut
56
+ finally:
57
+ loop.remove_reader(sys.stdin)
58
+
59
+
60
+ def main() -> None:
61
+ p = argparse.ArgumentParser(prog="assertify")
62
+ sub = p.add_subparsers(dest="cmd")
63
+ sub.add_parser("setup", help="One-time setup: install the browser, check encryption, show how to connect")
64
+ sub.add_parser("serve", help="Run the MCP server over stdio (default)")
65
+ lg = sub.add_parser("login", help="Log in to a site in a visible browser and save the session")
66
+ lg.add_argument("args", nargs="+", metavar="[name] url", help="Name is optional: defaults to the account's email")
67
+ lg.add_argument("--domain", action="append", default=[], help="Allowed domain (repeatable)")
68
+ lg.add_argument(
69
+ "--read-only", action="store_true", help="Agents may browse and read, but never type, submit, send or buy"
70
+ )
71
+ ls = sub.add_parser("list", help="List saved sessions")
72
+ ls.add_argument("site", nargs="?", help="Only this site's logins")
73
+ rn = sub.add_parser("rename", help="Rename a saved session")
74
+ rn.add_argument("profile")
75
+ rn.add_argument("new_name")
76
+ dl = sub.add_parser("delete", help="Delete a saved session")
77
+ dl.add_argument("profile")
78
+ rl = sub.add_parser("relay", help="Serve the Assertify phone app to remote agents (Claude mobile, Codex)")
79
+ rl.add_argument("--host", default="127.0.0.1", help="Address to listen on (0.0.0.0 for your LAN)")
80
+ rl.add_argument("--port", type=int, default=8765)
81
+ rl.add_argument("--public-url", help="The https:// address agents reach this relay at (e.g. a tunnel)")
82
+ rl.add_argument("--new-token", action="store_true", help="Make a new secret (unpairs the phone)")
83
+ args = p.parse_args()
84
+ if args.cmd not in (None, "serve"):
85
+ # Windows consoles may not be UTF-8; never crash on a ✓ or an emoji in a page title.
86
+ for stream in (sys.stdout, sys.stderr):
87
+ if hasattr(stream, "reconfigure"):
88
+ stream.reconfigure(errors="replace")
89
+
90
+ try:
91
+ if args.cmd in (None, "serve"):
92
+ from .server import run
93
+
94
+ run()
95
+ elif args.cmd == "setup":
96
+ from .install import setup
97
+
98
+ sys.exit(setup())
99
+ elif args.cmd == "login":
100
+ if len(args.args) > 2:
101
+ p.error("login takes [name] url")
102
+ name, url = (None, args.args[0]) if len(args.args) == 1 else args.args
103
+ asyncio.run(_login(name, url, args.domain, args.read_only))
104
+ elif args.cmd == "list":
105
+ print(json.dumps(Vault().list(args.site), indent=2, default=str))
106
+ elif args.cmd == "rename":
107
+ Vault().rename(args.profile, args.new_name)
108
+ print(f"Renamed to {args.new_name!r}.")
109
+ elif args.cmd == "relay":
110
+ from .relay import serve
111
+
112
+ serve(args.host, args.port, args.public_url, args.new_token)
113
+ elif args.cmd == "delete":
114
+ print("Deleted." if Vault().delete(args.profile) else "No such profile.")
115
+ except VaultError as e:
116
+ sys.exit(f"error: {e}")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
assertify/files.py ADDED
@@ -0,0 +1,177 @@
1
+ """Turn a downloaded file into text the agent can read: CSV/TSV as a table, Excel's first sheets,
2
+ PDF text, and plain text formats. Files come from websites, so this only ever reads them."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import csv
7
+ import io
8
+ import re
9
+ import zipfile
10
+ from pathlib import Path
11
+ from xml.etree import ElementTree
12
+
13
+ TEXT_TYPES = {
14
+ ".txt",
15
+ ".log",
16
+ ".md",
17
+ ".json",
18
+ ".xml",
19
+ ".html",
20
+ ".htm",
21
+ ".ics",
22
+ ".vcf",
23
+ ".yaml",
24
+ ".yml",
25
+ ".srt",
26
+ ".vtt",
27
+ }
28
+ MAX_UNZIPPED = 50 * 1024 * 1024 # an .xlsx that unpacks bigger than this isn't read
29
+
30
+
31
+ def safe_name(name: str) -> str:
32
+ name = Path(name.replace("\\", "/")).name
33
+ name = re.sub(r"[^\w.\- ()\[\]]+", "_", name).strip(". ")[:120]
34
+ return name or "download"
35
+
36
+
37
+ def decode(data: bytes) -> str:
38
+ for enc in ("utf-16", "utf-8-sig") if data[:2] in (b"\xff\xfe", b"\xfe\xff") else ("utf-8-sig",):
39
+ try:
40
+ return data.decode(enc)
41
+ except UnicodeDecodeError:
42
+ pass
43
+ return data.decode("cp1252", errors="replace")
44
+
45
+
46
+ def table(rows: list[list[str]], max_rows: int = 200) -> str:
47
+ """Rows -> a Markdown table (first row is the header)."""
48
+ rows = [r for r in rows if any(c.strip() for c in r)]
49
+ if not rows:
50
+ return "(empty)"
51
+ width = max(len(r) for r in rows)
52
+
53
+ def cell(c: str) -> str:
54
+ c = " ".join(c.split()).replace("|", "\\|")
55
+ return c[:80] + "…" if len(c) > 80 else c
56
+
57
+ lines = []
58
+ for i, r in enumerate(rows[: max_rows + 1]):
59
+ lines.append("| " + " | ".join(cell(c) for c in r + [""] * (width - len(r))) + " |")
60
+ if i == 0:
61
+ lines.append("|" + " --- |" * width)
62
+ if len(rows) > max_rows + 1:
63
+ lines.append(f"(+{len(rows) - max_rows - 1} more rows; the full file is saved)")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def _csv(text: str, delimiter: str | None) -> str:
68
+ if delimiter is None:
69
+ try:
70
+ delimiter = csv.Sniffer().sniff(text[:5000], delimiters=",;\t|").delimiter
71
+ except csv.Error:
72
+ delimiter = ","
73
+ return table(list(csv.reader(io.StringIO(text), delimiter=delimiter)))
74
+
75
+
76
+ def _xlsx(data: bytes) -> str:
77
+ ns = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
78
+ rel = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
79
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
80
+ if sum(i.file_size for i in z.infolist()) > MAX_UNZIPPED:
81
+ return "(spreadsheet too big to read; the full file is saved)"
82
+
83
+ def xml(name: str):
84
+ return ElementTree.fromstring(z.read(name)) if name in z.namelist() else None
85
+
86
+ shared = []
87
+ sst = xml("xl/sharedStrings.xml")
88
+ if sst is not None:
89
+ shared = ["".join(t.text or "" for t in si.iter(f"{{{ns['m']}}}t")) for si in sst.findall("m:si", ns)]
90
+ book, rels = xml("xl/workbook.xml"), xml("xl/_rels/workbook.xml.rels")
91
+ targets = {r.get("Id"): r.get("Target") for r in rels} if rels is not None else {}
92
+ sheets = []
93
+ for sh in book.findall("m:sheets/m:sheet", ns) if book is not None else []:
94
+ target = targets.get(sh.get(rel), "")
95
+ target = target.lstrip("/") if target.startswith("/") else "xl/" + target
96
+ sheets.append((sh.get("name"), target))
97
+ out = []
98
+ for name, path in sheets[:5]:
99
+ root = xml(path)
100
+ if root is None:
101
+ continue
102
+ rows = []
103
+ for row in root.iter(f"{{{ns['m']}}}row"):
104
+ cells: dict[int, str] = {}
105
+ for c in row.findall("m:c", ns):
106
+ col = _col(c.get("r", ""))
107
+ v = c.find("m:v", ns)
108
+ kind = c.get("t")
109
+ if kind == "s" and v is not None:
110
+ val = shared[int(v.text)] if v.text and v.text.isdigit() and int(v.text) < len(shared) else ""
111
+ elif kind == "inlineStr":
112
+ val = "".join(t.text or "" for t in c.iter(f"{{{ns['m']}}}t"))
113
+ else:
114
+ val = v.text if v is not None and v.text else ""
115
+ cells[col if col >= 0 else len(cells)] = val
116
+ if cells:
117
+ rows.append([cells.get(i, "") for i in range(max(cells) + 1)])
118
+ out.append(f"### Sheet: {name}\n{table(rows)}")
119
+ if len(sheets) > 5:
120
+ out.append(f"(+{len(sheets) - 5} more sheets)")
121
+ return "\n\n".join(out) or "(no sheets found)"
122
+
123
+
124
+ def _col(ref: str) -> int:
125
+ n = 0
126
+ for ch in re.match(r"[A-Z]*", ref).group():
127
+ n = n * 26 + ord(ch) - 64
128
+ return n - 1
129
+
130
+
131
+ def _pdf(path: Path) -> str:
132
+ try:
133
+ from pypdf import PdfReader
134
+ except ImportError:
135
+ return "(PDF saved; text extraction isn't available here)"
136
+ reader = PdfReader(str(path))
137
+ if reader.is_encrypted:
138
+ try:
139
+ reader.decrypt("")
140
+ except Exception:
141
+ return "(the PDF is password-protected)"
142
+ parts = []
143
+ for i, page in enumerate(reader.pages[:100]):
144
+ text = (page.extract_text() or "").strip()
145
+ if text:
146
+ parts.append(f"--- page {i + 1} ---\n{text}")
147
+ if len(reader.pages) > 100:
148
+ parts.append(f"(+{len(reader.pages) - 100} more pages)")
149
+ return "\n\n".join(parts) or "(no text in this PDF; it may be scanned images)"
150
+
151
+
152
+ def _html(text: str) -> str:
153
+ text = re.sub(r"(?is)<(script|style)\b.*?</\1>", " ", text)
154
+ text = re.sub(r"(?i)<br\s*/?>|</(p|div|tr|li|h\d)>", "\n", text)
155
+ text = re.sub(r"<[^>]+>", " ", text)
156
+ return re.sub(r"[ \t]+", " ", text).strip()
157
+
158
+
159
+ def read(path: Path, content_type: str = "") -> str | None:
160
+ """Readable text for a file, or None for kinds it can't read (images, archives, ...)."""
161
+ ext = path.suffix.lower()
162
+ ctype = content_type.split(";")[0].strip().lower()
163
+ data = path.read_bytes()
164
+ if ext == ".pdf" or ctype == "application/pdf" or data[:5] == b"%PDF-":
165
+ return _pdf(path)
166
+ if ext in (".xlsx", ".xlsm") or "spreadsheetml" in ctype:
167
+ try:
168
+ return _xlsx(data)
169
+ except (zipfile.BadZipFile, ElementTree.ParseError, KeyError):
170
+ return None
171
+ if ext in (".csv", ".tsv") or ctype in ("text/csv", "text/tab-separated-values"):
172
+ return _csv(decode(data), "\t" if ext == ".tsv" else None)
173
+ if ext in (".html", ".htm") or ctype == "text/html":
174
+ return _html(decode(data))
175
+ if ext in TEXT_TYPES or ctype.startswith("text/") or ctype in ("application/json", "application/xml"):
176
+ return decode(data)
177
+ return None
assertify/human.py ADDED
@@ -0,0 +1,95 @@
1
+ """Human-like pacing: random pauses, curved mouse paths, uneven typing.
2
+
3
+ Bot detectors look for instant, pixel-perfect, perfectly regular input. This module makes
4
+ actions look like a person's. Turn it off with ASSERTIFY_HUMANIZE=0 (tests, trusted sites).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import math
11
+ import os
12
+ import random
13
+
14
+ from playwright.async_api import Page
15
+
16
+
17
+ def enabled() -> bool:
18
+ return os.environ.get("ASSERTIFY_HUMANIZE", "1") != "0"
19
+
20
+
21
+ async def pause(lo: float = 0.4, hi: float = 1.3, slow: float = 1.0) -> None:
22
+ """Think time between actions. `slow` > 1 for sites that are strict about automation."""
23
+ if enabled():
24
+ await asyncio.sleep(random.uniform(lo, hi) * slow)
25
+
26
+
27
+ def point_in(box: dict) -> tuple[float, float]:
28
+ """A random point near the middle of an element, never the exact center."""
29
+ if not enabled():
30
+ return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
31
+ return (
32
+ box["x"] + box["width"] * random.uniform(0.3, 0.7),
33
+ box["y"] + box["height"] * random.uniform(0.3, 0.7),
34
+ )
35
+
36
+
37
+ def _path(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
38
+ """Eased curve from (x0, y0) to (x1, y1) with a random bend."""
39
+ dist = math.hypot(x1 - x0, y1 - y0)
40
+ steps = max(8, min(40, int(dist / 25)))
41
+ bend = min(dist * 0.25, 120)
42
+ cx = (x0 + x1) / 2 + random.uniform(-bend, bend)
43
+ cy = (y0 + y1) / 2 + random.uniform(-bend, bend)
44
+ pts = []
45
+ for i in range(1, steps + 1):
46
+ t = i / steps
47
+ t = t * t * (3 - 2 * t) # ease in and out
48
+ pts.append(
49
+ (
50
+ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
51
+ (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1,
52
+ )
53
+ )
54
+ return pts
55
+
56
+
57
+ async def click_at(page: Page, start: tuple[float, float], x: float, y: float) -> None:
58
+ """Move the mouse along a curve, hover briefly, then press and release."""
59
+ if not enabled():
60
+ await page.mouse.click(x, y)
61
+ return
62
+ for px, py in _path(*start, x, y):
63
+ await page.mouse.move(px, py)
64
+ await asyncio.sleep(random.uniform(0.004, 0.016))
65
+ await asyncio.sleep(random.uniform(0.05, 0.25))
66
+ await page.mouse.down()
67
+ await asyncio.sleep(random.uniform(0.04, 0.12))
68
+ await page.mouse.up()
69
+
70
+
71
+ async def type_text(page: Page, text: str) -> None:
72
+ """Type one key at a time with uneven gaps and the odd pause between words."""
73
+ if not enabled():
74
+ await page.keyboard.insert_text(text)
75
+ return
76
+ fast = len(text) > 200 # long text: still keystrokes, just quicker
77
+ for ch in text:
78
+ await page.keyboard.type(ch)
79
+ delay = random.uniform(0.035, 0.14)
80
+ if ch == " " and random.random() < 0.15:
81
+ delay += random.uniform(0.1, 0.4)
82
+ await asyncio.sleep(delay / 4 if fast else delay)
83
+
84
+
85
+ async def wheel(page: Page, dy: float) -> None:
86
+ """Scroll in a few uneven flicks instead of one jump."""
87
+ if not enabled():
88
+ await page.mouse.wheel(0, dy)
89
+ return
90
+ left = dy
91
+ while abs(left) > 1:
92
+ step = math.copysign(min(abs(left), random.uniform(90, 260)), left)
93
+ await page.mouse.wheel(0, step)
94
+ left -= step
95
+ await asyncio.sleep(random.uniform(0.03, 0.12))
assertify/icon.png ADDED
Binary file
assertify/install.py ADDED
@@ -0,0 +1,107 @@
1
+ """Browser install + `assertify setup`.
2
+
3
+ Python packages can't run code at install time, so the browser download happens either in
4
+ `assertify setup` (recommended, right after installing) or automatically on first use.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import platform
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from playwright.async_api import Playwright
17
+
18
+ # Real Chrome first: it looks like a normal browser to sites. Then full Chromium, whose "new" headless
19
+ # mode is far harder to detect than the default headless shell.
20
+ AUTO_CHANNELS = ["chrome", "chromium"]
21
+ # Playwright adds --enable-automation, which shows the "controlled by automated software" bar and
22
+ # sets navigator.webdriver. Drop it.
23
+ STEALTH = {"ignore_default_args": ["--enable-automation"]}
24
+
25
+
26
+ def install_chromium(out=None) -> bool:
27
+ """Download Playwright's Chromium (about 300 MB, once). Output goes to `out` (stderr by default,
28
+ because stdout is the MCP protocol channel when running as a server)."""
29
+ out = out or sys.stderr
30
+ print("assertify: downloading Chromium (one time, about 300 MB)...", file=out, flush=True)
31
+ cmd = [sys.executable, "-m", "playwright", "install", "chromium"]
32
+ result = subprocess.run(cmd, stdout=out, stderr=out)
33
+ return result.returncode == 0
34
+
35
+
36
+ async def launch(pw: Playwright, **kwargs):
37
+ """Launch a browser. ASSERTIFY_BROWSER=chrome|chromium|msedge picks one; by default the user's
38
+ Chrome, else Chromium (installed on first use if needed), else Edge."""
39
+ kwargs = {**STEALTH, **kwargs}
40
+ choice = os.environ.get("ASSERTIFY_BROWSER", "").lower()
41
+ channels = [choice] if choice else AUTO_CHANNELS
42
+ errors = []
43
+ for channel in channels:
44
+ try:
45
+ return await pw.chromium.launch(channel=channel, **kwargs)
46
+ except Exception as e:
47
+ errors.append(f"{channel}: {str(e).splitlines()[0]}")
48
+ if "chromium" in channels and os.environ.get("ASSERTIFY_NO_AUTO_INSTALL") != "1":
49
+ if await asyncio.to_thread(install_chromium):
50
+ return await pw.chromium.launch(channel="chromium", **kwargs)
51
+ if not choice:
52
+ try:
53
+ return await pw.chromium.launch(channel="msedge", **kwargs)
54
+ except Exception as e:
55
+ errors.append(f"msedge: {str(e).splitlines()[0]}")
56
+ raise RuntimeError(
57
+ "No browser available (" + "; ".join(errors) + "). Run `assertify setup` in a terminal to fix this."
58
+ )
59
+
60
+
61
+ def _mcp_command() -> list[str]:
62
+ """Absolute command that runs this exact install, wherever it lives."""
63
+ return [sys.executable, "-m", "assertify.cli", "serve"]
64
+
65
+
66
+ def setup() -> int:
67
+ from playwright.sync_api import sync_playwright
68
+
69
+ from .vault import Vault, VaultError, vault_home
70
+
71
+ ok = True
72
+ print("assertify setup\n")
73
+
74
+ # 1. Browser
75
+ with sync_playwright() as pw:
76
+ have = Path(pw.chromium.executable_path).exists()
77
+ if have:
78
+ print("✓ Chromium is installed")
79
+ else:
80
+ print("… Chromium not found, installing it now")
81
+ if install_chromium(out=sys.stdout):
82
+ print("✓ Chromium installed")
83
+ else:
84
+ ok = False
85
+ print("✗ Chromium download failed. Check your connection and run `assertify setup` again.")
86
+ print(" (assertify will still use Google Chrome or Microsoft Edge if one is installed.)")
87
+ if platform.system() == "Linux":
88
+ print(" Linux: if the browser fails to start, install its system libraries with:")
89
+ print(f" sudo {sys.executable} -m playwright install-deps chromium")
90
+
91
+ # 2. Encryption key
92
+ try:
93
+ _ = Vault().key
94
+ source = "ASSERTIFY_PASSPHRASE" if os.environ.get("ASSERTIFY_PASSPHRASE") else "OS keychain"
95
+ print(f"✓ Encryption key ready ({source}); vault at {vault_home()}")
96
+ except VaultError as e:
97
+ ok = False
98
+ print(f"✗ {e}")
99
+
100
+ # 3. How to connect it
101
+ cmd = " ".join(f'"{c}"' if " " in c else c for c in _mcp_command())
102
+ print("\nConnect it to your AI client:")
103
+ print(f" Claude Code: claude mcp add -s user assertify -- {cmd}")
104
+ args = ", ".join(f'"{c}"' for c in _mcp_command()[1:])
105
+ print(f' JSON config: "assertify": {{"command": "{sys.executable}", "args": [{args}]}}')
106
+ print("\nThen save a login, e.g.: assertify login github https://github.com/login")
107
+ return 0 if ok else 1