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/__init__.py +1 -0
- assertify/__main__.py +3 -0
- assertify/browser.py +1791 -0
- assertify/cli.py +120 -0
- assertify/files.py +177 -0
- assertify/human.py +95 -0
- assertify/icon.png +0 -0
- assertify/install.py +107 -0
- assertify/native.py +259 -0
- assertify/prompt.py +75 -0
- assertify/relay.py +629 -0
- assertify/safety.py +252 -0
- assertify/server.py +495 -0
- assertify/storage.py +287 -0
- assertify/vault.py +157 -0
- assertify_mcp-0.6.3.dist-info/METADATA +414 -0
- assertify_mcp-0.6.3.dist-info/RECORD +20 -0
- assertify_mcp-0.6.3.dist-info/WHEEL +4 -0
- assertify_mcp-0.6.3.dist-info/entry_points.txt +3 -0
- assertify_mcp-0.6.3.dist-info/licenses/LICENSE +21 -0
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
|