synapse-orch-ai 1.9.2 → 1.9.3
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.
- package/backend/core/browser_automation.py +91 -0
- package/backend/core/egress_guard.py +23 -2
- package/backend/core/egress_proxy.py +229 -0
- package/backend/core/server.py +47 -9
- package/backend/tools/web_scraper.py +92 -28
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- /package/frontend-build/.next/static/{YrERDQ96GLlwEG7rNI1lz → OIEoX5QA1J1xrSoahxwNa}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{YrERDQ96GLlwEG7rNI1lz → OIEoX5QA1J1xrSoahxwNa}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{YrERDQ96GLlwEG7rNI1lz → OIEoX5QA1J1xrSoahxwNa}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What the Browser Automation server may be asked to do.
|
|
2
|
+
|
|
3
|
+
The server is Playwright's MCP server (`@playwright/mcp`), started by `core/server.py`, on by default, and called
|
|
4
|
+
by agents that read web pages, so what a page says can steer it. Its tools are offered through `gated()`:
|
|
5
|
+
|
|
6
|
+
* **Only the tools reviewed for the pinned version.** None that run code in the server's own process
|
|
7
|
+
(`browser_run_code_unsafe`; Playwright's own description calls it RCE-equivalent), run a model's script in the
|
|
8
|
+
page (`browser_evaluate`), or read local files into a page (`browser_file_upload`, `browser_drop`). A tool a
|
|
9
|
+
later version adds is not offered until someone reviews it and adds it here.
|
|
10
|
+
* **A `url` is http(s)**, or `about:blank`: never `file:`, `chrome:`, `data:` and the like.
|
|
11
|
+
* **A `filename` stays in the server's folder**: relative, no `..`. The server is started with that folder as its
|
|
12
|
+
working directory and output directory, so it is where its own files land.
|
|
13
|
+
|
|
14
|
+
Where the browser may connect is not decided here: its network goes through the egress proxy
|
|
15
|
+
(`core.egress_proxy`). BRW1's browser component replaces this server and its gate (`core/browser/gate.py`).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import PurePosixPath, PureWindowsPath
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
#: The server's name in `core/server.py`'s list, and the package it runs, pinned: `npx -y @playwright/mcp` ran
|
|
24
|
+
#: whatever was newest the first time, and a new version's new tools reached agents unreviewed.
|
|
25
|
+
BROWSER_AUTOMATION = "Browser Automation"
|
|
26
|
+
PLAYWRIGHT_MCP = "@playwright/mcp@0.0.83"
|
|
27
|
+
|
|
28
|
+
#: The tools of the pinned version that are offered. Reviewed 2026-10-28 against what 0.0.83 lists.
|
|
29
|
+
OFFERED = frozenset({
|
|
30
|
+
"browser_navigate", "browser_navigate_back", "browser_tabs", "browser_snapshot", "browser_find",
|
|
31
|
+
"browser_wait_for", "browser_take_screenshot", "browser_console_messages", "browser_network_requests",
|
|
32
|
+
"browser_network_request", "browser_click", "browser_type", "browser_fill_form", "browser_select_option",
|
|
33
|
+
"browser_press_key", "browser_hover", "browser_drag", "browser_handle_dialog", "browser_resize",
|
|
34
|
+
"browser_emulate_media", "browser_close",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
_URL_ARGS = ("url",)
|
|
38
|
+
_FILE_ARGS = ("filename",)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def refusal(name: str, arguments: dict | None) -> str | None:
|
|
42
|
+
"""Why this call is not made, or None when it may be."""
|
|
43
|
+
if name not in OFFERED:
|
|
44
|
+
return f"{name} is not available in Synapse's browser."
|
|
45
|
+
args = arguments or {}
|
|
46
|
+
for key in _URL_ARGS:
|
|
47
|
+
url = args.get(key)
|
|
48
|
+
if url is None:
|
|
49
|
+
continue
|
|
50
|
+
scheme = urlsplit(str(url)).scheme.lower()
|
|
51
|
+
if scheme not in ("http", "https") and str(url) != "about:blank":
|
|
52
|
+
return f"{name} opens http and https addresses only."
|
|
53
|
+
for key in _FILE_ARGS:
|
|
54
|
+
filename = args.get(key)
|
|
55
|
+
if filename is None:
|
|
56
|
+
continue
|
|
57
|
+
text = str(filename)
|
|
58
|
+
if (text.startswith(("/", "\\", "~")) or PureWindowsPath(text).drive
|
|
59
|
+
or ".." in PurePosixPath(text.replace("\\", "/")).parts):
|
|
60
|
+
return f"{name} saves only inside its own folder: give a file name, not a path."
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Gated:
|
|
65
|
+
"""The server's session, offering only `OFFERED` and refusing what `refusal` refuses. Anything else (a ping,
|
|
66
|
+
initialize) is the session's own."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, session: Any) -> None:
|
|
69
|
+
self._session = session
|
|
70
|
+
|
|
71
|
+
def __getattr__(self, name: str) -> Any:
|
|
72
|
+
return getattr(self._session, name)
|
|
73
|
+
|
|
74
|
+
async def list_tools(self, *args: Any, **kwargs: Any) -> Any:
|
|
75
|
+
result = await self._session.list_tools(*args, **kwargs)
|
|
76
|
+
return result.model_copy(update={"tools": [t for t in result.tools if t.name in OFFERED]})
|
|
77
|
+
|
|
78
|
+
async def call_tool(self, name: str, arguments: dict | None = None, *args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
reason = refusal(name, arguments)
|
|
80
|
+
if reason is not None:
|
|
81
|
+
import mcp.types as types
|
|
82
|
+
|
|
83
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=reason)], isError=True)
|
|
84
|
+
return await self._session.call_tool(name, arguments, *args, **kwargs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gated(server_name: str, session: Any) -> Any:
|
|
88
|
+
"""`session`, gated when it is the Browser Automation server's; any other server's as it is."""
|
|
89
|
+
if server_name != BROWSER_AUTOMATION or isinstance(session, _Gated):
|
|
90
|
+
return session
|
|
91
|
+
return _Gated(session)
|
|
@@ -149,13 +149,34 @@ async def check_host(host: str) -> None:
|
|
|
149
149
|
raise BlockedEgress("that URL has no host")
|
|
150
150
|
if host in allowed_hosts():
|
|
151
151
|
return
|
|
152
|
+
_check_addresses(host, await _addresses(host))
|
|
152
153
|
|
|
154
|
+
|
|
155
|
+
async def checked_addresses(host: str) -> list[str]:
|
|
156
|
+
"""The addresses behind `host`, every one of them public, or raise `BlockedEgress`.
|
|
157
|
+
|
|
158
|
+
For a caller that opens the connection itself (`core.egress_proxy`): it dials
|
|
159
|
+
one of these and never the name again, so the address it checked is the
|
|
160
|
+
address it reaches. A host in `EGRESS_ALLOW_HOSTS` is resolved, not checked.
|
|
161
|
+
"""
|
|
162
|
+
host = host.strip().lower().strip("[]")
|
|
163
|
+
if not host:
|
|
164
|
+
raise BlockedEgress("that URL has no host")
|
|
165
|
+
addresses = await _addresses(host)
|
|
166
|
+
if host not in allowed_hosts():
|
|
167
|
+
_check_addresses(host, addresses)
|
|
168
|
+
return addresses
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _addresses(host: str) -> list[str]:
|
|
153
172
|
try:
|
|
154
173
|
ipaddress.ip_address(host)
|
|
155
|
-
|
|
174
|
+
return [host]
|
|
156
175
|
except ValueError:
|
|
157
|
-
|
|
176
|
+
return await resolve(host)
|
|
177
|
+
|
|
158
178
|
|
|
179
|
+
def _check_addresses(host: str, addresses: list[str]) -> None:
|
|
159
180
|
for address in addresses:
|
|
160
181
|
reason = reject_address(address)
|
|
161
182
|
if reason is not None:
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""One egress proxy for every browser Synapse starts.
|
|
2
|
+
|
|
3
|
+
`core/egress_guard.py` decides where a fetch may go. A browser makes its own
|
|
4
|
+
requests — redirects, a page's scripts, in-page fetches, WebSockets, service
|
|
5
|
+
workers — and a hook inside the browser does not see every one of them. A
|
|
6
|
+
proxy does: the browser is started with this one as its proxy, Playwright puts
|
|
7
|
+
loopback through it too (`<-loopback>` in Chromium's bypass list), and it opens
|
|
8
|
+
no connection the guard refuses.
|
|
9
|
+
|
|
10
|
+
It also closes the window the guard leaves open on purpose. The guard resolves
|
|
11
|
+
a name, and the fetch it guards resolves it again; here the address that was
|
|
12
|
+
checked (`egress_guard.checked_addresses`) is the address dialled, and the name
|
|
13
|
+
is never looked up a second time.
|
|
14
|
+
|
|
15
|
+
What it speaks is the least a browser needs: `CONNECT host:port` for TLS and
|
|
16
|
+
WebSockets, and an absolute-form `http://` request for plain HTTP, sent on as
|
|
17
|
+
one request per connection (`Connection: close`) so a second request on the
|
|
18
|
+
same connection cannot name another host. A refusal is a `403` whose body is
|
|
19
|
+
the guard's message (it names the host asked for, never an address it
|
|
20
|
+
resolved to); anything that is not a proxy request is a `400`.
|
|
21
|
+
|
|
22
|
+
It listens on loopback and needs no credentials: what it reaches is the public
|
|
23
|
+
internet, which anything on the machine reaches already.
|
|
24
|
+
|
|
25
|
+
Deliberately stdlib-only, like the guard: the scraper's subprocess starts its own.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import sys
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
from core import egress_guard
|
|
34
|
+
|
|
35
|
+
#: What a Chromium behind this proxy is started with. UDP never goes through a proxy: WebRTC takes the proxied path
|
|
36
|
+
#: or none, and QUIC, which WebTransport rides with no route in its way, is off.
|
|
37
|
+
CHROMIUM_ARGS = ("--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--disable-quic")
|
|
38
|
+
|
|
39
|
+
#: A request head larger than this is not one a browser sends.
|
|
40
|
+
_HEAD_LIMIT = 64 * 1024
|
|
41
|
+
_HEAD_TIMEOUT_SECONDS = 30.0
|
|
42
|
+
CONNECT_TIMEOUT_SECONDS = 10.0
|
|
43
|
+
#: After the browser closes its side, how long the other side has to finish.
|
|
44
|
+
_DRAIN_SECONDS = 30.0
|
|
45
|
+
|
|
46
|
+
_REASONS = {400: "Bad Request", 403: "Forbidden", 502: "Bad Gateway"}
|
|
47
|
+
|
|
48
|
+
#: Headers meant for the proxy itself, never sent on.
|
|
49
|
+
_FOR_THE_PROXY = {"proxy-connection", "proxy-authorization", "proxy-authenticate"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _BadRequest(ValueError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Request:
|
|
57
|
+
def __init__(self, head: bytes):
|
|
58
|
+
lines = head[:-4].decode("latin-1").split("\r\n")
|
|
59
|
+
parts = lines[0].split(" ")
|
|
60
|
+
if len(parts) != 3 or not parts[2].startswith("HTTP/"):
|
|
61
|
+
raise _BadRequest("not an HTTP request")
|
|
62
|
+
self.method, target, version = parts
|
|
63
|
+
self.tunnel = self.method.upper() == "CONNECT"
|
|
64
|
+
try:
|
|
65
|
+
if self.tunnel:
|
|
66
|
+
url = urlsplit(f"//{target}")
|
|
67
|
+
self.host, self.port = url.hostname, url.port
|
|
68
|
+
if not self.host or not self.port:
|
|
69
|
+
raise _BadRequest("CONNECT needs host:port")
|
|
70
|
+
self.forward = b""
|
|
71
|
+
return
|
|
72
|
+
url = urlsplit(target)
|
|
73
|
+
self.host, self.port = url.hostname, url.port or 80
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise _BadRequest(f"not an address: {exc}") from None
|
|
76
|
+
if url.scheme != "http" or not self.host:
|
|
77
|
+
raise _BadRequest("a proxy request is CONNECT host:port or an absolute http:// URL")
|
|
78
|
+
|
|
79
|
+
headers = []
|
|
80
|
+
for line in lines[1:]:
|
|
81
|
+
name, _, value = line.partition(":")
|
|
82
|
+
if name.strip().lower() not in _FOR_THE_PROXY:
|
|
83
|
+
headers.append((name.strip(), value.strip()))
|
|
84
|
+
connection = {v.strip().lower() for n, value in headers if n.lower() == "connection"
|
|
85
|
+
for v in value.split(",")}
|
|
86
|
+
upgrade = "upgrade" in connection and any(n.lower() == "upgrade" for n, _ in headers)
|
|
87
|
+
if not upgrade:
|
|
88
|
+
headers = [(n, v) for n, v in headers if n.lower() not in ("connection", "keep-alive")]
|
|
89
|
+
headers.append(("Connection", "close"))
|
|
90
|
+
path = (url.path or "/") + (f"?{url.query}" if url.query else "")
|
|
91
|
+
self.forward = "".join([f"{self.method} {path} {version}\r\n",
|
|
92
|
+
*(f"{n}: {v}\r\n" for n, v in headers), "\r\n"]).encode("latin-1")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EgressProxy:
|
|
96
|
+
"""A loopback proxy that dials only what `core.egress_guard` allows. `start()` once, `close()` when done."""
|
|
97
|
+
|
|
98
|
+
host = "127.0.0.1"
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self.port = 0
|
|
102
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
103
|
+
self._server: asyncio.base_events.Server | None = None
|
|
104
|
+
self._starting: asyncio.Future | None = None
|
|
105
|
+
self._writers: set[asyncio.StreamWriter] = set()
|
|
106
|
+
self._closed = False
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def url(self) -> str:
|
|
110
|
+
return f"http://{self.host}:{self.port}"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def closed(self) -> bool:
|
|
114
|
+
return self._closed
|
|
115
|
+
|
|
116
|
+
async def start(self) -> str:
|
|
117
|
+
"""Listen, once; every caller waits for the same start."""
|
|
118
|
+
if self._starting is None:
|
|
119
|
+
self.loop = asyncio.get_running_loop()
|
|
120
|
+
self._starting = asyncio.ensure_future(self._listen())
|
|
121
|
+
await asyncio.shield(self._starting)
|
|
122
|
+
return self.url
|
|
123
|
+
|
|
124
|
+
async def _listen(self) -> None:
|
|
125
|
+
self._server = await asyncio.start_server(self._serve, self.host, 0, limit=_HEAD_LIMIT)
|
|
126
|
+
self.port = self._server.sockets[0].getsockname()[1]
|
|
127
|
+
|
|
128
|
+
async def close(self) -> None:
|
|
129
|
+
self._closed = True
|
|
130
|
+
if self._server is not None:
|
|
131
|
+
self._server.close()
|
|
132
|
+
for writer in list(self._writers):
|
|
133
|
+
writer.close()
|
|
134
|
+
if self._server is not None:
|
|
135
|
+
await self._server.wait_closed()
|
|
136
|
+
|
|
137
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
138
|
+
self._writers.add(writer)
|
|
139
|
+
upstream: asyncio.StreamWriter | None = None
|
|
140
|
+
host = ""
|
|
141
|
+
try:
|
|
142
|
+
try:
|
|
143
|
+
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), _HEAD_TIMEOUT_SECONDS)
|
|
144
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError, ConnectionError):
|
|
145
|
+
return
|
|
146
|
+
request = _Request(head)
|
|
147
|
+
host = request.host
|
|
148
|
+
addresses = await egress_guard.checked_addresses(host)
|
|
149
|
+
up_reader, upstream = await _dial(addresses, request.port)
|
|
150
|
+
self._writers.add(upstream)
|
|
151
|
+
if request.tunnel:
|
|
152
|
+
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
153
|
+
else:
|
|
154
|
+
upstream.write(request.forward)
|
|
155
|
+
await _relay(reader, writer, up_reader, upstream)
|
|
156
|
+
except _BadRequest as exc:
|
|
157
|
+
_answer(writer, 400, str(exc))
|
|
158
|
+
except egress_guard.BlockedEgress as exc:
|
|
159
|
+
print(f"egress proxy: refused {host}: {exc}", file=sys.stderr)
|
|
160
|
+
_answer(writer, 403, str(exc))
|
|
161
|
+
except (OSError, TimeoutError):
|
|
162
|
+
_answer(writer, 502, f"could not reach {host}")
|
|
163
|
+
finally:
|
|
164
|
+
for w in (upstream, writer):
|
|
165
|
+
if w is not None:
|
|
166
|
+
self._writers.discard(w)
|
|
167
|
+
w.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _dial(addresses: list[str], port: int):
|
|
171
|
+
"""A connection to the first of `addresses` that answers. Never the name: the addresses are what was checked."""
|
|
172
|
+
last: BaseException = OSError("no address")
|
|
173
|
+
for address in addresses:
|
|
174
|
+
try:
|
|
175
|
+
return await asyncio.wait_for(asyncio.open_connection(address, port), CONNECT_TIMEOUT_SECONDS)
|
|
176
|
+
except (OSError, TimeoutError) as exc:
|
|
177
|
+
last = exc
|
|
178
|
+
raise last
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
|
182
|
+
try:
|
|
183
|
+
while data := await src.read(65536):
|
|
184
|
+
dst.write(data)
|
|
185
|
+
await dst.drain()
|
|
186
|
+
except ConnectionError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _relay(client_r, client_w, up_r, up_w) -> None:
|
|
191
|
+
"""Both ways until the far side is done. The browser closing its side gives the far side a moment to finish."""
|
|
192
|
+
outbound = asyncio.ensure_future(_pipe(client_r, up_w))
|
|
193
|
+
inbound = asyncio.ensure_future(_pipe(up_r, client_w))
|
|
194
|
+
try:
|
|
195
|
+
done, _ = await asyncio.wait({outbound, inbound}, return_when=asyncio.FIRST_COMPLETED)
|
|
196
|
+
if inbound not in done:
|
|
197
|
+
if up_w.can_write_eof():
|
|
198
|
+
try:
|
|
199
|
+
up_w.write_eof()
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
await asyncio.wait({inbound}, timeout=_DRAIN_SECONDS)
|
|
203
|
+
finally:
|
|
204
|
+
for task in (outbound, inbound):
|
|
205
|
+
task.cancel()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _answer(writer: asyncio.StreamWriter, status: int, reason: str) -> None:
|
|
209
|
+
body = f"{reason}\n".encode()
|
|
210
|
+
try:
|
|
211
|
+
writer.write(f"HTTP/1.1 {status} {_REASONS[status]}\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
|
212
|
+
f"Content-Length: {len(body)}\r\nX-Synapse-Egress: refused\r\nConnection: close\r\n\r\n"
|
|
213
|
+
.encode() + body)
|
|
214
|
+
except (OSError, RuntimeError):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_shared: EgressProxy | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def shared() -> EgressProxy:
|
|
222
|
+
"""This event loop's proxy, started on first use. A closed one, or one from another loop, is replaced."""
|
|
223
|
+
global _shared
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
proxy = _shared
|
|
226
|
+
if proxy is None or proxy.closed or (proxy.loop is not None and proxy.loop is not loop):
|
|
227
|
+
proxy = _shared = EgressProxy()
|
|
228
|
+
await proxy.start()
|
|
229
|
+
return proxy
|
package/backend/core/server.py
CHANGED
|
@@ -88,6 +88,7 @@ _settings = load_settings()
|
|
|
88
88
|
if _settings.get("ollama_base_url"):
|
|
89
89
|
os.environ["OLLAMA_BASE_URL"] = _settings["ollama_base_url"]
|
|
90
90
|
|
|
91
|
+
from core.browser_automation import gated
|
|
91
92
|
from core.tools_registry import ALL_NATIVE_TOOLS
|
|
92
93
|
TOOLS_LIST = dict(ALL_NATIVE_TOOLS)
|
|
93
94
|
|
|
@@ -178,6 +179,18 @@ def _filesystem_server_spec() -> dict:
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
|
|
182
|
+
#: Synapse's egress proxy (`core.egress_proxy`), which Browser Automation's browser goes through; lifespan starts it.
|
|
183
|
+
_browser_proxy_url: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _native_tool_args(name: str) -> list[str]:
|
|
187
|
+
"""What a native tool's server is started with beyond its script. The scraper saves screenshots in its own
|
|
188
|
+
folder in the vault and nowhere else (F1066)."""
|
|
189
|
+
if name == "web_scraper":
|
|
190
|
+
return ["--output-dir", str((Path(DATA_DIR) / "vault" / "tool_outputs" / "screenshots").resolve())]
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
|
|
181
194
|
def _build_native_mcp_servers() -> list[dict]:
|
|
182
195
|
"""
|
|
183
196
|
Build the list of native MCP servers to connect at startup.
|
|
@@ -198,12 +211,31 @@ def _build_native_mcp_servers() -> list[dict]:
|
|
|
198
211
|
# Fallback for old configs
|
|
199
212
|
env_dict["PLAYWRIGHT_BROWSERS_PATH"] = os.path.expanduser("~/.cache/ms-playwright")
|
|
200
213
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"
|
|
206
|
-
|
|
214
|
+
from core.browser_automation import BROWSER_AUTOMATION, PLAYWRIGHT_MCP
|
|
215
|
+
|
|
216
|
+
if _browser_proxy_url is None:
|
|
217
|
+
# Never without its proxy: lifespan starts it before any server is spawned.
|
|
218
|
+
print("Warning: the egress proxy is not running — not starting Browser Automation.")
|
|
219
|
+
else:
|
|
220
|
+
# Its own folder is its output directory and its working directory: the server reads and writes files
|
|
221
|
+
# only inside those two. It was the relative "data/vault/playwright", resolved against the home
|
|
222
|
+
# directory it was started in.
|
|
223
|
+
folder = (Path(DATA_DIR) / "vault" / "playwright").resolve()
|
|
224
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
# Its browser's own flags, in a file outside that folder (its tools write there), rewritten at every start.
|
|
226
|
+
from core import egress_proxy
|
|
227
|
+
|
|
228
|
+
config = Path(DATA_DIR) / "browser_automation" / "config.json"
|
|
229
|
+
config.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
config.write_text(json.dumps({"browser": {"launchOptions": {"args": list(egress_proxy.CHROMIUM_ARGS)}}}))
|
|
231
|
+
servers.append({
|
|
232
|
+
"name": BROWSER_AUTOMATION,
|
|
233
|
+
"command": _NPX_CMD,
|
|
234
|
+
"args": ["-y", PLAYWRIGHT_MCP, "--browser", "chrome", "--output-dir", str(folder),
|
|
235
|
+
"--proxy-server", _browser_proxy_url, "--config", str(config)],
|
|
236
|
+
"env": env_dict,
|
|
237
|
+
"cwd": str(folder),
|
|
238
|
+
})
|
|
207
239
|
|
|
208
240
|
# --- Google Workspace MCP Server (Gmail, Drive, Calendar) ---
|
|
209
241
|
google_env = _get_google_oauth_env()
|
|
@@ -392,10 +424,14 @@ def _log_security_posture() -> None:
|
|
|
392
424
|
|
|
393
425
|
@asynccontextmanager
|
|
394
426
|
async def lifespan(app: FastAPI):
|
|
395
|
-
global exit_stack
|
|
427
|
+
global exit_stack, _browser_proxy_url
|
|
396
428
|
print("Starting Multi-Agent Orchestrator...")
|
|
397
429
|
_log_security_posture()
|
|
398
430
|
exit_stack = AsyncExitStack()
|
|
431
|
+
# Before any browser is started: every one goes through it.
|
|
432
|
+
from core import egress_proxy
|
|
433
|
+
|
|
434
|
+
_browser_proxy_url = (await egress_proxy.shared()).url
|
|
399
435
|
|
|
400
436
|
if _settings.get("coding_agent_enabled"):
|
|
401
437
|
try:
|
|
@@ -415,7 +451,7 @@ async def lifespan(app: FastAPI):
|
|
|
415
451
|
|
|
416
452
|
server_params = StdioServerParameters(
|
|
417
453
|
command=sys.executable,
|
|
418
|
-
args=[script_path],
|
|
454
|
+
args=[script_path, *_native_tool_args(agent_name)],
|
|
419
455
|
env=env
|
|
420
456
|
)
|
|
421
457
|
|
|
@@ -479,13 +515,15 @@ async def lifespan(app: FastAPI):
|
|
|
479
515
|
command=cmd,
|
|
480
516
|
args=mcp_cfg["args"],
|
|
481
517
|
env=env,
|
|
482
|
-
cwd=str(Path.home()),
|
|
518
|
+
cwd=mcp_cfg.get("cwd") or str(Path.home()),
|
|
483
519
|
)
|
|
484
520
|
read, write = await exit_stack.enter_async_context(stdio_client(server_params))
|
|
485
521
|
session = await exit_stack.enter_async_context(
|
|
486
522
|
ClientSession(read, write, read_timeout_seconds=_SESSION_READ_TIMEOUT)
|
|
487
523
|
)
|
|
488
524
|
await session.initialize()
|
|
525
|
+
# The Browser Automation server offers only what its gate allows (`core.browser_automation`).
|
|
526
|
+
session = gated(mcp_name, session)
|
|
489
527
|
|
|
490
528
|
agent_sessions[mcp_name] = session
|
|
491
529
|
|
|
@@ -4,9 +4,10 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
-
import
|
|
7
|
+
import time
|
|
8
8
|
import weakref
|
|
9
9
|
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
10
11
|
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
|
|
@@ -37,6 +38,8 @@ logging.getLogger("crawl4ai.async_webcrawler").setLevel(logging.ERROR)
|
|
|
37
38
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
|
38
39
|
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
|
|
39
40
|
|
|
41
|
+
from core import egress_proxy
|
|
42
|
+
from core.file_roots import within
|
|
40
43
|
from core.egress_guard import BlockedEgress, check_host, check_url
|
|
41
44
|
|
|
42
45
|
# ---------------------------------------------------------------------------
|
|
@@ -44,7 +47,52 @@ from core.egress_guard import BlockedEgress, check_host, check_url
|
|
|
44
47
|
# ---------------------------------------------------------------------------
|
|
45
48
|
|
|
46
49
|
MAX_MARKDOWN_LEN = 50_000
|
|
47
|
-
MAX_SCREENSHOT_B64 = 2_000_000 #
|
|
50
|
+
MAX_SCREENSHOT_B64 = 2_000_000 # saved in OUTPUT_DIR above this size
|
|
51
|
+
|
|
52
|
+
#: Where `screenshot_url` saves a screenshot, and the only place it writes (F1066): the folder this server was
|
|
53
|
+
#: started with (`--output-dir`; the backend gives it `<vault>/tool_outputs/screenshots`). None on a worker's
|
|
54
|
+
#: shared server, which serves every tenant on its task and has no folder of theirs: nothing is saved there.
|
|
55
|
+
OUTPUT_DIR: Path | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def output_dir_from(argv: list[str]) -> Path | None:
|
|
59
|
+
"""`--output-dir <path>` from this server's arguments, or None."""
|
|
60
|
+
if "--output-dir" in argv[:-1]:
|
|
61
|
+
return Path(argv[argv.index("--output-dir") + 1])
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _screenshot_target(requested: str | None) -> Path:
|
|
66
|
+
"""Where a screenshot asked to be saved as `requested` goes: inside `OUTPUT_DIR`, links and `..` resolved, or
|
|
67
|
+
`ValueError`. A bare name is placed in the folder; no name gets one."""
|
|
68
|
+
folder = OUTPUT_DIR
|
|
69
|
+
if folder is None:
|
|
70
|
+
raise ValueError("Screenshots are not saved to disk here: leave out output_path and the image comes back "
|
|
71
|
+
"in the answer.")
|
|
72
|
+
path = Path(requested or f"screenshot-{time.strftime('%Y%m%d-%H%M%S')}.png").expanduser()
|
|
73
|
+
if not path.is_absolute():
|
|
74
|
+
path = folder / path
|
|
75
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
if not within(path, [folder]):
|
|
77
|
+
raise ValueError(f"Screenshots are saved only inside {folder}: give a file name such as 'page.png'.")
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_new(path: Path, data: bytes) -> Path:
|
|
82
|
+
"""Write `data` to a file that did not exist: `path`, else `path` with -1, -2… before its suffix. Never over a
|
|
83
|
+
file, and never through a link at the last step."""
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
86
|
+
for n in range(1000):
|
|
87
|
+
candidate = path if n == 0 else path.with_name(f"{path.stem}-{n}{path.suffix}")
|
|
88
|
+
try:
|
|
89
|
+
fd = os.open(candidate, flags, 0o644)
|
|
90
|
+
except FileExistsError:
|
|
91
|
+
continue
|
|
92
|
+
with os.fdopen(fd, "wb") as fh:
|
|
93
|
+
fh.write(data)
|
|
94
|
+
return candidate
|
|
95
|
+
raise ValueError(f"too many screenshots named {path.name}")
|
|
48
96
|
|
|
49
97
|
# ---------------------------------------------------------------------------
|
|
50
98
|
# Crawler singleton — started lazily on first use
|
|
@@ -112,21 +160,29 @@ _crawler: AsyncWebCrawler | None = None
|
|
|
112
160
|
_crawler_lock = asyncio.Lock()
|
|
113
161
|
|
|
114
162
|
|
|
163
|
+
def browser_config(local_proxy: str | None = None) -> BrowserConfig:
|
|
164
|
+
"""`local_proxy` is Synapse's own egress proxy (`core.egress_proxy`): the browser's whole network goes through
|
|
165
|
+
it, so what the route guard does not see (a page's WebSockets, its service workers) is refused there."""
|
|
166
|
+
return BrowserConfig(
|
|
167
|
+
headless=True,
|
|
168
|
+
browser_type="chromium",
|
|
169
|
+
enable_stealth=True,
|
|
170
|
+
verbose=False,
|
|
171
|
+
user_agent=(
|
|
172
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
173
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
174
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
175
|
+
),
|
|
176
|
+
extra_args=list(egress_proxy.CHROMIUM_ARGS),
|
|
177
|
+
**({"proxy_config": {"server": local_proxy}} if local_proxy else {}),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
115
181
|
async def get_crawler() -> AsyncWebCrawler:
|
|
116
182
|
global _crawler
|
|
117
183
|
async with _crawler_lock:
|
|
118
184
|
if _crawler is None:
|
|
119
|
-
config =
|
|
120
|
-
headless=True,
|
|
121
|
-
browser_type="chromium",
|
|
122
|
-
enable_stealth=True,
|
|
123
|
-
verbose=False,
|
|
124
|
-
user_agent=(
|
|
125
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
126
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
127
|
-
"Chrome/120.0.0.0 Safari/537.36"
|
|
128
|
-
),
|
|
129
|
-
)
|
|
185
|
+
config = browser_config((await egress_proxy.shared()).url)
|
|
130
186
|
_crawler = AsyncWebCrawler(config=config)
|
|
131
187
|
_crawler.crawler_strategy.set_hook(
|
|
132
188
|
"on_page_context_created", _attach_egress_guard
|
|
@@ -407,8 +463,8 @@ async def list_tools() -> list[types.Tool]:
|
|
|
407
463
|
name="screenshot_url",
|
|
408
464
|
description=(
|
|
409
465
|
"Take a screenshot of a webpage and return it as a base64-encoded PNG. "
|
|
410
|
-
"Supports full-page capture and optional
|
|
411
|
-
"If the screenshot exceeds 2 MB it is
|
|
466
|
+
"Supports full-page capture and optional saving to the vault's screenshots folder. "
|
|
467
|
+
"If the screenshot exceeds 2 MB it is saved there automatically."
|
|
412
468
|
),
|
|
413
469
|
inputSchema={
|
|
414
470
|
"type": "object",
|
|
@@ -423,7 +479,7 @@ async def list_tools() -> list[types.Tool]:
|
|
|
423
479
|
},
|
|
424
480
|
"output_path": {
|
|
425
481
|
"type": "string",
|
|
426
|
-
"description": "
|
|
482
|
+
"description": "File name to save the PNG under, in the vault's screenshots folder (e.g. 'page.png'). If omitted, returns base64 in the response."
|
|
427
483
|
},
|
|
428
484
|
"full_page": {
|
|
429
485
|
"type": "boolean",
|
|
@@ -700,6 +756,13 @@ async def _handle_screenshot_url(args: dict) -> list[types.TextContent]:
|
|
|
700
756
|
return _err("url is required")
|
|
701
757
|
if (blocked := await _preflight(url)) is not None:
|
|
702
758
|
return _err(blocked)
|
|
759
|
+
# Where it would be saved, refused before the page is fetched.
|
|
760
|
+
target = None
|
|
761
|
+
if args.get("output_path"):
|
|
762
|
+
try:
|
|
763
|
+
target = _screenshot_target(str(args["output_path"]))
|
|
764
|
+
except ValueError as exc:
|
|
765
|
+
return _err(str(exc))
|
|
703
766
|
|
|
704
767
|
delay = float(args.get("delay", 0))
|
|
705
768
|
full_page = args.get("full_page", False)
|
|
@@ -720,21 +783,21 @@ async def _handle_screenshot_url(args: dict) -> list[types.TextContent]:
|
|
|
720
783
|
return _err("Screenshot capture returned no data")
|
|
721
784
|
|
|
722
785
|
screenshot_b64: str = result.screenshot
|
|
723
|
-
output_path: str | None = args.get("output_path")
|
|
724
786
|
|
|
725
|
-
#
|
|
726
|
-
if
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
787
|
+
# Too large to hand back inline: saved in the folder, under a name of its own.
|
|
788
|
+
if target is None and len(screenshot_b64) > MAX_SCREENSHOT_B64:
|
|
789
|
+
try:
|
|
790
|
+
target = _screenshot_target(None)
|
|
791
|
+
except ValueError:
|
|
792
|
+
return _err(f"The screenshot is larger than {MAX_SCREENSHOT_B64 // 1_000_000} MB and is not saved "
|
|
793
|
+
"to disk here; take it without full_page")
|
|
730
794
|
|
|
731
|
-
if
|
|
795
|
+
if target is not None:
|
|
732
796
|
try:
|
|
733
797
|
img_bytes = base64.b64decode(screenshot_b64)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
except Exception as exc:
|
|
798
|
+
saved = _write_new(target, img_bytes)
|
|
799
|
+
return _ok({"success": True, "url": url, "saved_to": str(saved), "size_bytes": len(img_bytes)})
|
|
800
|
+
except (OSError, ValueError) as exc:
|
|
738
801
|
return _err(f"Failed to save screenshot: {exc}")
|
|
739
802
|
|
|
740
803
|
return _ok({"success": True, "url": url, "screenshot_base64": screenshot_b64, "format": "png"})
|
|
@@ -858,7 +921,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
858
921
|
# ---------------------------------------------------------------------------
|
|
859
922
|
|
|
860
923
|
async def main() -> None:
|
|
861
|
-
global _crawler
|
|
924
|
+
global _crawler, OUTPUT_DIR
|
|
925
|
+
OUTPUT_DIR = output_dir_from(sys.argv)
|
|
862
926
|
async with stdio_server() as (read_stream, write_stream):
|
|
863
927
|
try:
|
|
864
928
|
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
OIEoX5QA1J1xrSoahxwNa
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"static/chunks/03~yq9q893hmn.js"
|
|
8
8
|
],
|
|
9
9
|
"lowPriorityFiles": [
|
|
10
|
-
"static/
|
|
11
|
-
"static/
|
|
12
|
-
"static/
|
|
10
|
+
"static/OIEoX5QA1J1xrSoahxwNa/_buildManifest.js",
|
|
11
|
+
"static/OIEoX5QA1J1xrSoahxwNa/_ssgManifest.js",
|
|
12
|
+
"static/OIEoX5QA1J1xrSoahxwNa/_clientMiddlewareManifest.js"
|
|
13
13
|
],
|
|
14
14
|
"rootMainFiles": [
|
|
15
15
|
"static/chunks/0qmh881w772ef.js",
|