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/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