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.
Files changed (47) hide show
  1. package/backend/core/browser_automation.py +91 -0
  2. package/backend/core/egress_guard.py +23 -2
  3. package/backend/core/egress_proxy.py +229 -0
  4. package/backend/core/server.py +47 -9
  5. package/backend/tools/web_scraper.py +92 -28
  6. package/frontend-build/.next/BUILD_ID +1 -1
  7. package/frontend-build/.next/build-manifest.json +3 -3
  8. package/frontend-build/.next/prerender-manifest.json +3 -3
  9. package/frontend-build/.next/server/app/_global-error.html +1 -1
  10. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  11. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  12. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/frontend-build/.next/server/app/_not-found.html +1 -1
  17. package/frontend-build/.next/server/app/_not-found.rsc +1 -1
  18. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  19. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  21. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  24. package/frontend-build/.next/server/app/index.html +1 -1
  25. package/frontend-build/.next/server/app/index.rsc +1 -1
  26. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  27. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +1 -1
  30. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  31. package/frontend-build/.next/server/app/login.html +1 -1
  32. package/frontend-build/.next/server/app/login.rsc +1 -1
  33. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +1 -1
  34. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +1 -1
  36. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  37. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  38. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  39. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  40. package/frontend-build/.next/server/pages/404.html +1 -1
  41. package/frontend-build/.next/server/pages/500.html +1 -1
  42. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  43. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  44. package/package.json +1 -1
  45. /package/frontend-build/.next/static/{YrERDQ96GLlwEG7rNI1lz → OIEoX5QA1J1xrSoahxwNa}/_buildManifest.js +0 -0
  46. /package/frontend-build/.next/static/{YrERDQ96GLlwEG7rNI1lz → OIEoX5QA1J1xrSoahxwNa}/_clientMiddlewareManifest.js +0 -0
  47. /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
- addresses = [host]
174
+ return [host]
156
175
  except ValueError:
157
- addresses = await resolve(host)
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
@@ -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
- servers.append({
202
- "name": "Browser Automation",
203
- "command": _NPX_CMD,
204
- "args": ["-y", "@playwright/mcp", "--browser", "chrome", "--output-dir", "data/vault/playwright"],
205
- "env": env_dict,
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 tempfile
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 # auto-save to tmp file above this size
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 = BrowserConfig(
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 save-to-disk. "
411
- "If the screenshot exceeds 2 MB it is automatically saved to a temp file."
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": "Absolute file path to save the PNG (e.g. '/tmp/screenshot.png'). If omitted, returns base64 in the response."
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
- # Auto-save to temp file if too large for inline embedding
726
- if not output_path and len(screenshot_b64) > MAX_SCREENSHOT_B64:
727
- tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
728
- output_path = tmp.name
729
- tmp.close()
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 output_path:
795
+ if target is not None:
732
796
  try:
733
797
  img_bytes = base64.b64decode(screenshot_b64)
734
- with open(output_path, "wb") as fh:
735
- fh.write(img_bytes)
736
- return _ok({"success": True, "url": url, "saved_to": output_path, "size_bytes": len(img_bytes)})
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
- YrERDQ96GLlwEG7rNI1lz
1
+ OIEoX5QA1J1xrSoahxwNa
@@ -7,9 +7,9 @@
7
7
  "static/chunks/03~yq9q893hmn.js"
8
8
  ],
9
9
  "lowPriorityFiles": [
10
- "static/YrERDQ96GLlwEG7rNI1lz/_buildManifest.js",
11
- "static/YrERDQ96GLlwEG7rNI1lz/_ssgManifest.js",
12
- "static/YrERDQ96GLlwEG7rNI1lz/_clientMiddlewareManifest.js"
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",