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/native.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""A login window in the user's own, un-automated Chrome.
|
|
2
|
+
|
|
3
|
+
Google (and some other sign-in providers) refuse to sign in inside a browser that an automation tool
|
|
4
|
+
is driving. So for logins Assertify starts the installed Chrome as a normal app, with a throwaway
|
|
5
|
+
profile folder and a DevTools port on 127.0.0.1 (a pipe would make Chrome flag itself as automated,
|
|
6
|
+
navigator.webdriver = true). While the user signs in,
|
|
7
|
+
it only asks the browser for its cookie jar, which pages can't see. Pages are read (localStorage,
|
|
8
|
+
which account is signed in) once, after the user says they are done. Then Chrome is closed and the
|
|
9
|
+
throwaway profile is deleted.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import websockets
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_chrome() -> str | None:
|
|
29
|
+
"""Path of an installed Chrome (or Chromium / Edge), or None."""
|
|
30
|
+
env = os.environ.get("ASSERTIFY_CHROME_PATH")
|
|
31
|
+
if env:
|
|
32
|
+
return env if Path(env).exists() else None
|
|
33
|
+
if sys.platform == "darwin":
|
|
34
|
+
names = ["Google Chrome", "Chromium", "Microsoft Edge"]
|
|
35
|
+
for base in ("/Applications", str(Path.home() / "Applications")):
|
|
36
|
+
for n in names:
|
|
37
|
+
p = Path(base) / f"{n}.app" / "Contents" / "MacOS" / n
|
|
38
|
+
if p.exists():
|
|
39
|
+
return str(p)
|
|
40
|
+
return None
|
|
41
|
+
if sys.platform == "win32":
|
|
42
|
+
roots = [os.environ.get(k) for k in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
|
|
43
|
+
for root in filter(None, roots):
|
|
44
|
+
for rel in (r"Google\Chrome\Application\chrome.exe", r"Microsoft\Edge\Application\msedge.exe"):
|
|
45
|
+
p = Path(root) / rel
|
|
46
|
+
if p.exists():
|
|
47
|
+
return str(p)
|
|
48
|
+
return None
|
|
49
|
+
for n in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"):
|
|
50
|
+
p = shutil.which(n)
|
|
51
|
+
if p:
|
|
52
|
+
return p
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _free_port() -> int:
|
|
57
|
+
with socket.socket() as sock:
|
|
58
|
+
sock.bind(("127.0.0.1", 0))
|
|
59
|
+
return sock.getsockname()[1]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ws_url(port: int) -> str:
|
|
63
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=2) as r:
|
|
64
|
+
return json.loads(r.read())["webSocketDebuggerUrl"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def isolated_eval(send, expression: str):
|
|
68
|
+
"""Evaluate JS in a fresh "isolated world" of a tab's main frame, over a CDP `send(method, params)`.
|
|
69
|
+
The page's scripts don't share its globals: a site that deletes window.localStorage (Discord) or
|
|
70
|
+
replaces built-ins can't hide anything from it, and can't see that it ran."""
|
|
71
|
+
frame = (await send("Page.getFrameTree"))["frameTree"]["frame"]["id"]
|
|
72
|
+
ctx = (await send("Page.createIsolatedWorld", {"frameId": frame, "worldName": "assertify"}))["executionContextId"]
|
|
73
|
+
res = await send(
|
|
74
|
+
"Runtime.evaluate",
|
|
75
|
+
{"expression": expression, "contextId": ctx, "returnByValue": True, "awaitPromise": True},
|
|
76
|
+
)
|
|
77
|
+
if res.get("exceptionDetails"):
|
|
78
|
+
raise RuntimeError(res["exceptionDetails"].get("text", "script failed"))
|
|
79
|
+
return res.get("result", {}).get("value")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def native_supported() -> bool:
|
|
83
|
+
"""ASSERTIFY_LOGIN_WINDOW=playwright goes back to the automated login window."""
|
|
84
|
+
return os.environ.get("ASSERTIFY_LOGIN_WINDOW", "").lower() != "playwright"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _to_cdp(c: dict) -> dict:
|
|
88
|
+
out = {k: c[k] for k in ("name", "value", "domain", "path", "httpOnly", "secure") if k in c}
|
|
89
|
+
if c.get("expires", -1) not in (-1, None):
|
|
90
|
+
out["expires"] = c["expires"]
|
|
91
|
+
if c.get("sameSite") in ("Strict", "Lax", "None"):
|
|
92
|
+
out["sameSite"] = c["sameSite"]
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _from_cdp(c: dict) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"name": c["name"],
|
|
99
|
+
"value": c["value"],
|
|
100
|
+
"domain": c["domain"],
|
|
101
|
+
"path": c.get("path", "/"),
|
|
102
|
+
"expires": -1 if c.get("session") else c.get("expires", -1),
|
|
103
|
+
"httpOnly": bool(c.get("httpOnly")),
|
|
104
|
+
"secure": bool(c.get("secure")),
|
|
105
|
+
"sameSite": c.get("sameSite") if c.get("sameSite") in ("Strict", "Lax", "None") else "Lax",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NativeChrome:
|
|
110
|
+
"""One Chrome process with a throwaway profile and a local DevTools port."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self.proc: subprocess.Popen | None = None
|
|
114
|
+
self.user_dir: str | None = None
|
|
115
|
+
self._ws = None
|
|
116
|
+
self._reader_task: asyncio.Task | None = None
|
|
117
|
+
self._next = 0
|
|
118
|
+
self._waiting: dict[int, asyncio.Future] = {}
|
|
119
|
+
self.closed = asyncio.Event()
|
|
120
|
+
|
|
121
|
+
async def start(self, chrome: str, url: str, cookies: list[dict] | None) -> None:
|
|
122
|
+
self.user_dir = tempfile.mkdtemp(prefix="assertify-login-") # 0700: only this user can read it
|
|
123
|
+
port = _free_port()
|
|
124
|
+
self.proc = subprocess.Popen(
|
|
125
|
+
[
|
|
126
|
+
chrome,
|
|
127
|
+
f"--user-data-dir={self.user_dir}",
|
|
128
|
+
"--remote-debugging-address=127.0.0.1",
|
|
129
|
+
# A fixed port: with port 0 or a pipe Chrome marks itself as automated (navigator.webdriver).
|
|
130
|
+
f"--remote-debugging-port={port}",
|
|
131
|
+
"--no-first-run",
|
|
132
|
+
"--no-default-browser-check",
|
|
133
|
+
"--new-window",
|
|
134
|
+
"about:blank",
|
|
135
|
+
],
|
|
136
|
+
stdin=subprocess.DEVNULL,
|
|
137
|
+
stdout=subprocess.DEVNULL,
|
|
138
|
+
stderr=subprocess.DEVNULL,
|
|
139
|
+
)
|
|
140
|
+
ws_url = None
|
|
141
|
+
for _ in range(150):
|
|
142
|
+
if self.proc.poll() is not None:
|
|
143
|
+
raise RuntimeError("Chrome exited right after starting.")
|
|
144
|
+
try:
|
|
145
|
+
ws_url = await asyncio.to_thread(_ws_url, port)
|
|
146
|
+
break
|
|
147
|
+
except OSError:
|
|
148
|
+
await asyncio.sleep(0.1)
|
|
149
|
+
if not ws_url:
|
|
150
|
+
raise RuntimeError("Chrome didn't open its DevTools port.")
|
|
151
|
+
self._ws = await websockets.connect(ws_url, max_size=None, open_timeout=10)
|
|
152
|
+
self._reader_task = asyncio.create_task(self._reader())
|
|
153
|
+
|
|
154
|
+
if cookies:
|
|
155
|
+
await self.send("Storage.setCookies", {"cookies": [_to_cdp(c) for c in cookies]})
|
|
156
|
+
blank = [t["targetId"] for t in await self.page_targets()]
|
|
157
|
+
await self.send("Target.createTarget", {"url": url})
|
|
158
|
+
for tid in blank: # drop the empty first tab
|
|
159
|
+
await self.send("Target.closeTarget", {"targetId": tid})
|
|
160
|
+
|
|
161
|
+
async def _reader(self) -> None:
|
|
162
|
+
try:
|
|
163
|
+
async for msg in self._ws:
|
|
164
|
+
try:
|
|
165
|
+
data = json.loads(msg)
|
|
166
|
+
except ValueError:
|
|
167
|
+
continue
|
|
168
|
+
fut = self._waiting.pop(data.get("id"), None)
|
|
169
|
+
if fut and not fut.done():
|
|
170
|
+
if "error" in data:
|
|
171
|
+
fut.set_exception(RuntimeError(data["error"].get("message", "CDP error")))
|
|
172
|
+
else:
|
|
173
|
+
fut.set_result(data.get("result", {}))
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
self.closed.set()
|
|
177
|
+
for fut in self._waiting.values():
|
|
178
|
+
if not fut.done():
|
|
179
|
+
fut.set_exception(RuntimeError("The browser was closed."))
|
|
180
|
+
self._waiting.clear()
|
|
181
|
+
|
|
182
|
+
async def send(self, method: str, params: dict | None = None, session: str | None = None) -> dict:
|
|
183
|
+
if self.closed.is_set() or self._ws is None:
|
|
184
|
+
raise RuntimeError("The browser was closed.")
|
|
185
|
+
self._next += 1
|
|
186
|
+
msg = {"id": self._next, "method": method, "params": params or {}}
|
|
187
|
+
if session:
|
|
188
|
+
msg["sessionId"] = session
|
|
189
|
+
fut = asyncio.get_running_loop().create_future()
|
|
190
|
+
self._waiting[self._next] = fut
|
|
191
|
+
try:
|
|
192
|
+
await self._ws.send(json.dumps(msg))
|
|
193
|
+
except Exception as e:
|
|
194
|
+
self._waiting.pop(self._next, None)
|
|
195
|
+
raise RuntimeError("The browser was closed.") from e
|
|
196
|
+
return await asyncio.wait_for(fut, timeout=15)
|
|
197
|
+
|
|
198
|
+
# ---- while the user signs in: browser-level calls only, nothing a page can notice ----
|
|
199
|
+
|
|
200
|
+
async def cookies(self) -> list[dict]:
|
|
201
|
+
res = await self.send("Storage.getCookies")
|
|
202
|
+
return [_from_cdp(c) for c in res.get("cookies", [])]
|
|
203
|
+
|
|
204
|
+
async def page_targets(self) -> list[dict]:
|
|
205
|
+
res = await self.send("Target.getTargets")
|
|
206
|
+
return [t for t in res.get("targetInfos", []) if t.get("type") == "page"]
|
|
207
|
+
|
|
208
|
+
async def bring_to_front(self) -> None:
|
|
209
|
+
pages = await self.page_targets()
|
|
210
|
+
if pages:
|
|
211
|
+
await self.send("Target.activateTarget", {"targetId": pages[-1]["targetId"]})
|
|
212
|
+
|
|
213
|
+
async def user_agent(self) -> str:
|
|
214
|
+
return (await self.send("Browser.getVersion")).get("userAgent", "")
|
|
215
|
+
|
|
216
|
+
# ---- after the user is done ----
|
|
217
|
+
|
|
218
|
+
async def evaluate_pages(self, expression: str, skip=None) -> list[tuple[str, object]]:
|
|
219
|
+
"""Run JS in every open web tab, except those `skip(url)` picks: [(url, value)].
|
|
220
|
+
It runs in a separate world of the page, so the page's own scripts can't see it or get in its way."""
|
|
221
|
+
out = []
|
|
222
|
+
for t in await self.page_targets():
|
|
223
|
+
if not t.get("url", "").startswith("http") or (skip and skip(t["url"])):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
sid = (await self.send("Target.attachToTarget", {"targetId": t["targetId"], "flatten": True}))[
|
|
227
|
+
"sessionId"
|
|
228
|
+
]
|
|
229
|
+
try:
|
|
230
|
+
value = await isolated_eval(lambda m, p=None, sid=sid: self.send(m, p, session=sid), expression)
|
|
231
|
+
finally:
|
|
232
|
+
await self.send("Target.detachFromTarget", {"sessionId": sid})
|
|
233
|
+
out.append((t["url"], value))
|
|
234
|
+
except Exception:
|
|
235
|
+
continue
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
async def close(self) -> None:
|
|
239
|
+
if self.proc and self.proc.poll() is None:
|
|
240
|
+
try:
|
|
241
|
+
await self.send("Browser.close")
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
for _ in range(30):
|
|
245
|
+
if self.proc.poll() is not None:
|
|
246
|
+
break
|
|
247
|
+
await asyncio.sleep(0.1)
|
|
248
|
+
if self.proc.poll() is None:
|
|
249
|
+
self.proc.kill()
|
|
250
|
+
if self._ws is not None:
|
|
251
|
+
try:
|
|
252
|
+
await self._ws.close()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
if self._reader_task:
|
|
256
|
+
self._reader_task.cancel()
|
|
257
|
+
if self.user_dir: # the throwaway profile holds the session in plain form: remove it
|
|
258
|
+
await asyncio.to_thread(shutil.rmtree, self.user_dir, True)
|
|
259
|
+
self.user_dir = None
|
assertify/prompt.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A small window where the user types things the agent must never see: the username and
|
|
2
|
+
password for a site's browser sign-in box (HTTP Basic/NTLM), and whether to trust its certificate."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import html
|
|
8
|
+
import string
|
|
9
|
+
|
|
10
|
+
FORM = string.Template("""<!doctype html><html><head><meta charset="utf-8"><title>Assertify: browser sign-in</title>
|
|
11
|
+
<style>
|
|
12
|
+
body { font: 15px -apple-system, system-ui, sans-serif; margin: 32px auto; max-width: 440px; color: #222; }
|
|
13
|
+
h1 { font-size: 19px; margin: 0 0 6px; } p { color: #555; margin: 0 0 18px; line-height: 1.4; }
|
|
14
|
+
label { display: block; margin: 14px 0 4px; font-weight: 600; }
|
|
15
|
+
input[type=text], input[type=password] { width: 100%; padding: 8px; font-size: 15px; box-sizing: border-box; }
|
|
16
|
+
.check { display: flex; gap: 8px; align-items: flex-start; font-weight: 400; margin-top: 18px; }
|
|
17
|
+
.warn { color: #8a5300; font-size: 13px; margin: 4px 0 0 24px; }
|
|
18
|
+
.buttons { margin-top: 24px; display: flex; gap: 10px; }
|
|
19
|
+
button { padding: 8px 18px; font-size: 15px; }
|
|
20
|
+
</style></head><body>
|
|
21
|
+
<h1>Browser settings for $site</h1>
|
|
22
|
+
<p>Only for sites that show the browser's own sign-in box, or use a certificate your browser doesn't trust
|
|
23
|
+
(common for company intranets and devices). The AI agent can't see what you type here; it's saved
|
|
24
|
+
encrypted with the login <b>$profile</b>.</p>
|
|
25
|
+
<form id="f">
|
|
26
|
+
<label for="u">Username</label><input type="text" id="u" autocomplete="off" value="$user">
|
|
27
|
+
<label for="p">Password</label><input type="password" id="p" autocomplete="off"
|
|
28
|
+
placeholder="$placeholder">
|
|
29
|
+
<label class="check"><input type="checkbox" id="c" $checked> Trust this site's certificate even if it
|
|
30
|
+
isn't valid (self-signed)</label>
|
|
31
|
+
<div class="warn">Only tick this for a site you control or your company runs. It turns off the check that
|
|
32
|
+
stops someone pretending to be the site.</div>
|
|
33
|
+
<div class="buttons"><button type="submit">Save</button><button type="button" id="x">Cancel</button></div>
|
|
34
|
+
</form>
|
|
35
|
+
<script>
|
|
36
|
+
document.getElementById('f').onsubmit = (e) => { e.preventDefault(); window.__done = {
|
|
37
|
+
save: true, user: document.getElementById('u').value, password: document.getElementById('p').value,
|
|
38
|
+
trust: document.getElementById('c').checked }; };
|
|
39
|
+
document.getElementById('x').onclick = () => { window.__done = {save: false}; };
|
|
40
|
+
</script></body></html>""")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def ask(pw, launch, profile: str, site: str, user: str, has_password: bool, trusted: bool) -> dict | None:
|
|
44
|
+
"""Show the form and wait for the user. Returns their answers, or None if they cancelled or closed it."""
|
|
45
|
+
browser = await launch(pw, headless=False)
|
|
46
|
+
page = await browser.new_page(viewport={"width": 560, "height": 560})
|
|
47
|
+
try:
|
|
48
|
+
await page.set_content(
|
|
49
|
+
FORM.substitute(
|
|
50
|
+
site=html.escape(site),
|
|
51
|
+
profile=html.escape(profile),
|
|
52
|
+
user=html.escape(user, quote=True),
|
|
53
|
+
placeholder="(unchanged, leave empty to keep it)" if has_password else "",
|
|
54
|
+
checked="checked" if trusted else "",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
await page.bring_to_front()
|
|
58
|
+
closed = asyncio.get_running_loop().create_future()
|
|
59
|
+
page.on("close", lambda _: closed.done() or closed.set_result(None))
|
|
60
|
+
answer = asyncio.ensure_future(page.wait_for_function("window.__done", timeout=600_000))
|
|
61
|
+
done, _ = await asyncio.wait([answer, closed], return_when=asyncio.FIRST_COMPLETED)
|
|
62
|
+
if answer not in done:
|
|
63
|
+
answer.cancel()
|
|
64
|
+
return None
|
|
65
|
+
result = await (await answer).json_value()
|
|
66
|
+
return result if result.get("save") else None
|
|
67
|
+
except Exception as e:
|
|
68
|
+
if page.is_closed() or not browser.is_connected(): # the user closed the window mid-way
|
|
69
|
+
return None
|
|
70
|
+
raise RuntimeError(f"The settings window failed: {str(e).splitlines()[0]}") from e
|
|
71
|
+
finally:
|
|
72
|
+
try:
|
|
73
|
+
await browser.close()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|