synapse-orch-ai 1.8.6 → 1.9.1
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/README.md +4 -0
- package/backend/core/config.py +10 -0
- package/backend/core/internal_auth.py +68 -14
- package/backend/core/mcp_client.py +62 -1
- package/backend/core/models.py +4 -0
- package/backend/core/notifications.py +251 -0
- package/backend/core/orchestration/engine.py +25 -0
- package/backend/core/orchestration/journal.py +263 -0
- package/backend/core/orchestration/runner.py +53 -4
- package/backend/core/orchestration/state.py +35 -3
- package/backend/core/orchestration/steps.py +140 -4
- package/backend/core/react_engine.py +39 -3
- package/backend/core/routes/api_v1.py +26 -2
- package/backend/core/routes/data.py +6 -0
- package/backend/core/routes/notifications.py +65 -0
- package/backend/core/routes/orchestrations.py +195 -5
- package/backend/core/routes/tools.py +14 -0
- package/backend/core/scale/worker_server_module.py +22 -1
- package/backend/core/scheduler.py +8 -1
- package/backend/core/server.py +43 -0
- package/backend/requirements-messaging.lock +1 -1
- package/backend/requirements.lock +6 -6
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/app-path-routes-manifest.json +2 -0
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/routes-manifest.json +14 -0
- 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/page.js +1 -1
- package/frontend-build/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +17 -16
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/api/notifications/stream/route/app-paths-manifest.json +3 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route/build-manifest.json +9 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route/server-reference-manifest.json +4 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js +6 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.map +5 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route_client-reference-manifest.js +3 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/app-paths-manifest.json +3 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/build-manifest.json +9 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/server-reference-manifest.json +4 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +6 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.map +5 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route_client-reference-manifest.js +3 -0
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +19 -18
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +19 -18
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page.js +1 -1
- package/frontend-build/.next/server/app/login/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +21 -20
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +21 -20
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/frontend-build/.next/server/app/page.js +1 -1
- package/frontend-build/.next/server/app/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app-paths-manifest.json +2 -0
- package/frontend-build/.next/server/chunks/0zjb_server_app_api_orchestrations_runs_[run_id]_events_stream_route_actions_0~mhgd~.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0fa0k5_._.js +3 -0
- package/frontend-build/.next/server/chunks/_next-internal_server_app_api_notifications_stream_route_actions_109.jhp.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0.9mzsa._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__0wxl56w._.js → [root-of-the-server]__05u5pmw._.js} +2 -2
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0fo_bex._.js +1 -1
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0n2i~oj._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__10.-84t._.js → [root-of-the-server]__10jhy6u._.js} +2 -2
- package/frontend-build/.next/server/chunks/ssr/_0.27p4o._.js +4 -0
- package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +15 -15
- package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_12ubua1._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
- package/frontend-build/.next/server/functions-config-manifest.json +6 -0
- 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/frontend-build/.next/static/chunks/{16j4jub4qpyf1.js → 02hfac~emo77v.js} +15 -15
- package/frontend-build/.next/static/chunks/047the8jlj6un.css +1 -0
- package/frontend-build/.next/static/chunks/0e_m35~cfcbua.js +1 -0
- package/frontend-build/.next/static/chunks/{0cwgnxu88mzyi.js → 0j-zkf3m3ioxl.js} +1 -1
- package/frontend-build/.next/static/chunks/0jo8lflqyoi98.js +1 -0
- package/frontend-build/.next/static/chunks/0qsyb4u8ttd2i.js +1 -0
- package/frontend-build/.next/static/chunks/{0g7bk6lyj687k.js → 0~o3t9bm0eiky.js} +1 -1
- package/frontend-build/.next/static/chunks/11a8am5wux-qu.js +2 -0
- package/frontend-build/.next/static/chunks/16~0az_az7p4a.js +1 -0
- package/package.json +1 -1
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0_30ktt._.js +0 -3
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0rj.gz.._.js +0 -3
- package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +0 -4
- package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_0__0o-2._.js +0 -3
- package/frontend-build/.next/static/chunks/03qf6ywkkwh~r.js +0 -1
- package/frontend-build/.next/static/chunks/0560sc9qpz__k.js +0 -2
- package/frontend-build/.next/static/chunks/0f8pg6_soqwt2.js +0 -1
- package/frontend-build/.next/static/chunks/0k0-nxx35rq9v.js +0 -1
- package/frontend-build/.next/static/chunks/0wtv8wcl1tn.d.js +0 -1
- package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +0 -1
- /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → sxSQKRDxOSif09kARGRDM}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → sxSQKRDxOSif09kARGRDM}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → sxSQKRDxOSif09kARGRDM}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ docker run -d \
|
|
|
64
64
|
|
|
65
65
|
Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseorch.com/getting-started/installation#docker) in the docs for custom ports and environment variable configuration.
|
|
66
66
|
|
|
67
|
+
> **Security:** publish only the frontend port `3000` — the backend (`8765`) serves an internal API and should not be exposed on `0.0.0.0` (docker-compose binds it to `127.0.0.1`). The container auto-generates a shared internal token so that surface is authenticated by default. For any network-reachable deployment, enable **login** (Settings → Security) and/or set `allow_stdio_mcp=false`, since registering a stdio MCP server launches local commands.
|
|
68
|
+
|
|
67
69
|
### Upgrading
|
|
68
70
|
|
|
69
71
|
| Install method | Upgrade command |
|
|
@@ -73,6 +75,8 @@ Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseor
|
|
|
73
75
|
| npm | `npm update -g synapse-orch-ai` |
|
|
74
76
|
| Docker | `docker pull synapseorchai/synapse-ai:latest` |
|
|
75
77
|
|
|
78
|
+
> **Upgrade note (security hardening):** the backend now auto-generates a shared internal token and, under docker-compose, binds its port to `127.0.0.1` by default. The UI and its API are unaffected (the frontend proxies the backend internally). If you previously called the **backend directly** on `:8765` (e.g. external API clients), either point them at the frontend instead — `http://host:3000/api/v1|v2/...` — or set `SYNAPSE_BACKEND_BIND=0.0.0.0` in `.env` to keep publishing `:8765`.
|
|
79
|
+
|
|
76
80
|
---
|
|
77
81
|
|
|
78
82
|
## Scale Mode
|
package/backend/core/config.py
CHANGED
|
@@ -108,6 +108,16 @@ def load_settings():
|
|
|
108
108
|
"messaging_enabled": True,
|
|
109
109
|
"embed_code": False,
|
|
110
110
|
"bash_allowed_dirs": [],
|
|
111
|
+
# ── MCP stdio server registration (security) ──
|
|
112
|
+
# stdio MCP servers launch local OS processes (npx/uvx/python/…). This is
|
|
113
|
+
# a powerful, admin-only capability. Allowed by default for local
|
|
114
|
+
# single-user use, but force-disabled in scale mode (multi-tenant /
|
|
115
|
+
# network-exposed) — see stdio_mcp_allowed(). See GHSA-3j67-x3j8-r32x.
|
|
116
|
+
"allow_stdio_mcp": True,
|
|
117
|
+
# Optional hardening: when non-empty, only these command basenames may be
|
|
118
|
+
# registered as stdio servers. Empty = allow any command in PATH.
|
|
119
|
+
# Recommended for exposed UIs: ["npx","uvx","uv","node","python","python3","docker"]
|
|
120
|
+
"mcp_command_allowlist": [],
|
|
111
121
|
"login_enabled": False,
|
|
112
122
|
"login_username": "",
|
|
113
123
|
"login_password_hash": "",
|
|
@@ -10,9 +10,23 @@ that try to hit /api/settings, /api/agents, etc. directly will get 403.
|
|
|
10
10
|
Rules:
|
|
11
11
|
- /api/v1/*, /api/v2/*, ... → SKIP (external versioned API; uses API key auth instead)
|
|
12
12
|
- /docs, /openapi.json, /redoc → SKIP (FastAPI docs)
|
|
13
|
-
- /
|
|
14
|
-
- /api
|
|
15
|
-
- If SYNAPSE_INTERNAL_TOKEN is not set →
|
|
13
|
+
- /auth/* → SKIP (OAuth redirects; browser-navigated, cannot carry a header)
|
|
14
|
+
- /api/*, /chat* → REQUIRE X-Synapse-Internal header
|
|
15
|
+
- If SYNAPSE_INTERNAL_TOKEN is not set → LOOPBACK-ONLY: allow requests whose
|
|
16
|
+
direct peer is 127.0.0.1/::1 (local dev, same-container proxy) and 403 any
|
|
17
|
+
remote caller. This closes the unauthenticated-RCE hole on network-exposed
|
|
18
|
+
token-less deployments (GHSA-3j67-x3j8-r32x). Docker images auto-generate a
|
|
19
|
+
token (docker/entrypoint.sh) so they never rely on this fallback.
|
|
20
|
+
|
|
21
|
+
Why /chat is gated even though it is not under /api/:
|
|
22
|
+
POST /chat and /chat/stream (core/routes/chat.py) run the full ReAct loop with
|
|
23
|
+
the complete tool surface — bash, execute_python, SQL, every MCP server. They
|
|
24
|
+
are mounted at the root, so the "not /api/ → skip" rule let any caller who
|
|
25
|
+
could reach the port drive them with no credentials at all.
|
|
26
|
+
The public, externally-authenticated chat API is /api/v1/chat (require_api_key)
|
|
27
|
+
— a different route. Root /chat is reached only by the Next.js route handler
|
|
28
|
+
(frontend/src/app/api/chat/**), which already sends X-Synapse-Internal via
|
|
29
|
+
backendHeaders(), so gating it is transparent to the UI.
|
|
16
30
|
"""
|
|
17
31
|
import os
|
|
18
32
|
import re
|
|
@@ -21,6 +35,27 @@ from fastapi import Request
|
|
|
21
35
|
from fastapi.responses import JSONResponse
|
|
22
36
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
23
37
|
|
|
38
|
+
# Direct-peer addresses treated as local. We deliberately check request.client
|
|
39
|
+
# (the immediate TCP peer), NOT X-Forwarded-For, which is attacker-spoofable.
|
|
40
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
41
|
+
|
|
42
|
+
# Root-mounted routes that are internal despite not living under /api/.
|
|
43
|
+
# Matched as exact path or path-prefixed segment so /chat and /chat/stream are
|
|
44
|
+
# both covered while an unrelated future route like /chatbot-docs is not.
|
|
45
|
+
_INTERNAL_ROOT_PREFIXES = ("/chat",)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_loopback(request: Request) -> bool:
|
|
49
|
+
client = request.client
|
|
50
|
+
return bool(client) and client.host in _LOOPBACK_HOSTS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_internal_root_route(path: str) -> bool:
|
|
54
|
+
return any(
|
|
55
|
+
path == prefix or path.startswith(prefix + "/")
|
|
56
|
+
for prefix in _INTERNAL_ROOT_PREFIXES
|
|
57
|
+
)
|
|
58
|
+
|
|
24
59
|
|
|
25
60
|
class InternalTokenMiddleware(BaseHTTPMiddleware):
|
|
26
61
|
"""Block direct access to internal /api/* routes without the internal token."""
|
|
@@ -32,29 +67,48 @@ class InternalTokenMiddleware(BaseHTTPMiddleware):
|
|
|
32
67
|
async def dispatch(self, request: Request, call_next):
|
|
33
68
|
path = request.url.path
|
|
34
69
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
# ── Skips run FIRST, independent of the internal token ──────────────────
|
|
71
|
+
# These routes are either externally authenticated (versioned API → API
|
|
72
|
+
# key) or intentionally public, so they must stay reachable even when the
|
|
73
|
+
# token is unset/loopback-gated. This lets external clients hit the open
|
|
74
|
+
# /api/v1|v2 API through the frontend proxy (or a directly-published
|
|
75
|
+
# backend port) without the internal frontend token.
|
|
38
76
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# /api/v<N> prefix so future versions are exempt automatically.
|
|
77
|
+
# External versioned API (v1, v2, ...) — uses require_api_key, not the
|
|
78
|
+
# internal token. Match any /api/v<N> prefix so future versions are exempt.
|
|
42
79
|
if re.match(r"^/api/v\d+(/|$)", path):
|
|
43
80
|
return await call_next(request)
|
|
44
81
|
|
|
45
|
-
#
|
|
82
|
+
# MCP OAuth callback — called by external OAuth providers, not the frontend.
|
|
46
83
|
if path == "/api/mcp/oauth/callback":
|
|
47
84
|
return await call_next(request)
|
|
48
85
|
|
|
49
|
-
#
|
|
86
|
+
# FastAPI docs.
|
|
50
87
|
if path in ("/docs", "/openapi.json", "/redoc"):
|
|
51
88
|
return await call_next(request)
|
|
52
89
|
|
|
53
|
-
#
|
|
54
|
-
|
|
90
|
+
# Non-API routes (auth redirects, health, websocket, etc.) — EXCEPT the
|
|
91
|
+
# root-mounted internal routes, which are gated below. /chat drives the
|
|
92
|
+
# full agent tool surface and must never be reachable unauthenticated.
|
|
93
|
+
if not path.startswith("/api/") and not _is_internal_root_route(path):
|
|
55
94
|
return await call_next(request)
|
|
56
95
|
|
|
57
|
-
#
|
|
96
|
+
# ── Internal surface — /api/* plus the root-mounted internal routes.
|
|
97
|
+
# The sensitive routes (settings, agents, mcp/servers, chat, …) that rely
|
|
98
|
+
# on the internal token for protection ──────────────────────────────────
|
|
99
|
+
if not self.token:
|
|
100
|
+
# No token configured → permissive ONLY for loopback callers. A remote
|
|
101
|
+
# client hitting a token-less backend directly is rejected, so the
|
|
102
|
+
# internal surface is never exposed unauthenticated over the network
|
|
103
|
+
# even in a misconfigured/bare-metal deployment (GHSA-3j67-x3j8-r32x).
|
|
104
|
+
if _is_loopback(request):
|
|
105
|
+
return await call_next(request)
|
|
106
|
+
return JSONResponse(
|
|
107
|
+
status_code=403,
|
|
108
|
+
content={"detail": "Forbidden: internal token not configured"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Token configured → require the matching header.
|
|
58
112
|
provided = request.headers.get("X-Synapse-Internal", "")
|
|
59
113
|
if provided != self.token:
|
|
60
114
|
return JSONResponse(
|
|
@@ -39,13 +39,44 @@ from mcp.client.stdio import stdio_client
|
|
|
39
39
|
from mcp.client.streamable_http import streamable_http_client
|
|
40
40
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
|
41
41
|
|
|
42
|
-
from core.config import DATA_DIR, MCP_SESSION_READ_TIMEOUT
|
|
42
|
+
from core.config import DATA_DIR, MCP_SESSION_READ_TIMEOUT, load_settings
|
|
43
43
|
import core.mcp_oauth_state as oauth_state
|
|
44
44
|
|
|
45
45
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
47
|
_SESSION_READ_TIMEOUT = timedelta(seconds=MCP_SESSION_READ_TIMEOUT)
|
|
48
48
|
|
|
49
|
+
|
|
50
|
+
# ── stdio MCP registration policy (security) ────────────────────────────────────
|
|
51
|
+
# stdio MCP servers launch arbitrary local OS processes (npx/uvx/python -c/…),
|
|
52
|
+
# which is the whole point of the transport but also an RCE-shaped capability.
|
|
53
|
+
# These helpers gate every path that can spawn one. See GHSA-3j67-x3j8-r32x.
|
|
54
|
+
|
|
55
|
+
def stdio_mcp_allowed() -> bool:
|
|
56
|
+
"""Whether stdio MCP servers may be registered/spawned on this deployment.
|
|
57
|
+
|
|
58
|
+
Disabled in scale mode (multi-tenant / network-exposed) and toggleable via
|
|
59
|
+
the ``allow_stdio_mcp`` setting. Fails open only if settings are unreadable,
|
|
60
|
+
to preserve local behaviour when config is unavailable.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
s = load_settings()
|
|
64
|
+
except Exception:
|
|
65
|
+
return True
|
|
66
|
+
if s.get("scale_mode_enabled"):
|
|
67
|
+
return False
|
|
68
|
+
return bool(s.get("allow_stdio_mcp", True))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_stdio_command_allowed(command: str) -> None:
|
|
72
|
+
"""Enforce the optional ``mcp_command_allowlist`` (empty list = allow any)."""
|
|
73
|
+
try:
|
|
74
|
+
allowlist = load_settings().get("mcp_command_allowlist") or []
|
|
75
|
+
except Exception:
|
|
76
|
+
allowlist = []
|
|
77
|
+
if allowlist and os.path.basename(command) not in allowlist:
|
|
78
|
+
raise ValueError(f"Command '{command}' is not in the allowed MCP command list.")
|
|
79
|
+
|
|
49
80
|
MCP_SERVERS_FILE = os.path.join(DATA_DIR, "mcp_servers.json")
|
|
50
81
|
MCP_TOKENS_DIR = os.path.join(DATA_DIR, "mcp_tokens")
|
|
51
82
|
|
|
@@ -231,6 +262,24 @@ class MCPClientManager:
|
|
|
231
262
|
print(f"Skipping '{name}': no command")
|
|
232
263
|
return None
|
|
233
264
|
|
|
265
|
+
# Gate at the sink, not only at the callers. Spawning a stdio server is
|
|
266
|
+
# arbitrary local command execution, so the check belongs on the code
|
|
267
|
+
# path that actually spawns — otherwise any new (or overlooked) call site
|
|
268
|
+
# silently bypasses it. POST /api/import did exactly that: it wrote a
|
|
269
|
+
# bundle's MCP configs to disk and called this method directly, skipping
|
|
270
|
+
# the add_server/reconnect_server/connect_all gates entirely.
|
|
271
|
+
if not stdio_mcp_allowed():
|
|
272
|
+
print(f"[MCP] Refusing to spawn stdio server '{name}': "
|
|
273
|
+
f"stdio MCP servers are disabled on this deployment.")
|
|
274
|
+
self._set_status(name, "disconnected")
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
check_stdio_command_allowed(command)
|
|
278
|
+
except ValueError as e:
|
|
279
|
+
print(f"[MCP] Refusing to spawn stdio server '{name}': {e}")
|
|
280
|
+
self._set_status(name, "disconnected")
|
|
281
|
+
return None
|
|
282
|
+
|
|
234
283
|
env = os.environ.copy()
|
|
235
284
|
env.update(env_vars)
|
|
236
285
|
|
|
@@ -432,6 +481,13 @@ class MCPClientManager:
|
|
|
432
481
|
|
|
433
482
|
server_type = config.get("server_type", "stdio")
|
|
434
483
|
if server_type == "stdio":
|
|
484
|
+
# Do not re-spawn persisted stdio servers when the capability is
|
|
485
|
+
# disabled — otherwise a saved (possibly malicious) config would
|
|
486
|
+
# execute on every boot. See GHSA-3j67-x3j8-r32x.
|
|
487
|
+
if not stdio_mcp_allowed():
|
|
488
|
+
print(f"[MCP] Skipping stdio server '{name}': stdio MCP disabled on this deployment.")
|
|
489
|
+
self._set_status(name, "disabled")
|
|
490
|
+
continue
|
|
435
491
|
session = await self.connect_stdio_server(config)
|
|
436
492
|
elif config.get("token"):
|
|
437
493
|
session = await self.connect_remote_server(config)
|
|
@@ -472,8 +528,11 @@ class MCPClientManager:
|
|
|
472
528
|
new_config: Dict[str, Any] = {"name": name, "label": label or name, "server_type": server_type, "status": "disconnected"}
|
|
473
529
|
|
|
474
530
|
if server_type == "stdio":
|
|
531
|
+
if not stdio_mcp_allowed():
|
|
532
|
+
raise PermissionError("stdio MCP server registration is disabled on this deployment.")
|
|
475
533
|
if not command:
|
|
476
534
|
raise ValueError("Command is required for stdio servers.")
|
|
535
|
+
check_stdio_command_allowed(command)
|
|
477
536
|
if shutil.which(command) is None:
|
|
478
537
|
hints = {"uvx": "Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
479
538
|
"npx": "Install Node.js/npm."}
|
|
@@ -527,6 +586,8 @@ class MCPClientManager:
|
|
|
527
586
|
|
|
528
587
|
server_type = config.get("server_type", "stdio")
|
|
529
588
|
if server_type == "stdio":
|
|
589
|
+
if not stdio_mcp_allowed():
|
|
590
|
+
raise PermissionError("stdio MCP servers are disabled on this deployment.")
|
|
530
591
|
session = await self.connect_stdio_server(config)
|
|
531
592
|
elif config.get("token"):
|
|
532
593
|
session = await self.connect_remote_server(config)
|
package/backend/core/models.py
CHANGED
|
@@ -131,6 +131,10 @@ class Settings(BaseModel):
|
|
|
131
131
|
login_username: str = ""
|
|
132
132
|
login_password_hash: str = ""
|
|
133
133
|
|
|
134
|
+
# Run notifications: messaging channel id to mirror run notifications to
|
|
135
|
+
# (human-input pauses, completions, failures). Empty = in-app only.
|
|
136
|
+
run_notification_channel_id: str = ""
|
|
137
|
+
|
|
134
138
|
# Scale / distributed execution settings
|
|
135
139
|
redis_url: str = ""
|
|
136
140
|
scale_postgres_url: str = ""
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""User-facing run notifications.
|
|
2
|
+
|
|
3
|
+
A process-wide hub that turns orchestration lifecycle events into
|
|
4
|
+
notifications the UI (and optionally a messaging channel) can consume:
|
|
5
|
+
|
|
6
|
+
- `human_input_required` → "… needs your input"
|
|
7
|
+
- `orchestration_complete`→ run completed / run failed
|
|
8
|
+
- `orchestration_error` → run failed
|
|
9
|
+
|
|
10
|
+
Fed by the runner pump via `observe_run_event` (registered on
|
|
11
|
+
`core.orchestration.runner.event_observers` at server startup), so every
|
|
12
|
+
entry point — UI, API, scheduler — produces notifications with no extra
|
|
13
|
+
wiring. Standalone-safe: a bounded in-memory ring with best-effort JSON
|
|
14
|
+
persistence, no external dependencies.
|
|
15
|
+
|
|
16
|
+
Delivery surfaces:
|
|
17
|
+
- `GET /api/notifications` + `/api/notifications/stream` (routes/notifications.py)
|
|
18
|
+
for the in-app provider (toasts, badges, browser notifications).
|
|
19
|
+
- Optional messaging dispatch: when settings.json has
|
|
20
|
+
`run_notification_channel_id` set to a configured messaging channel id,
|
|
21
|
+
each notification is also sent there (`notify_chat_id` or the channel's
|
|
22
|
+
last active chat).
|
|
23
|
+
"""
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
NOTIFICATIONS_FILE = Path(__file__).parent.parent / "logs" / "notifications.json"
|
|
30
|
+
CAPACITY = 200
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotificationHub:
|
|
34
|
+
def __init__(self, capacity: int = CAPACITY, persist_path: Path = NOTIFICATIONS_FILE):
|
|
35
|
+
self._capacity = capacity
|
|
36
|
+
self._path = persist_path
|
|
37
|
+
self._items: list[dict] = self._load()
|
|
38
|
+
self._next_id = (self._items[-1]["id"] + 1) if self._items else 1
|
|
39
|
+
self._subs: set[asyncio.Queue] = set()
|
|
40
|
+
# run_id → orchestration display name (cached from orchestration_start)
|
|
41
|
+
self._orch_names: dict[str, str] = {}
|
|
42
|
+
# Runs whose terminal notification already fired — engine failure paths
|
|
43
|
+
# can emit both orchestration_error and orchestration_complete(failed).
|
|
44
|
+
self._terminal_notified: set[str] = set()
|
|
45
|
+
self._server_module = None
|
|
46
|
+
|
|
47
|
+
# ── persistence ──────────────────────────────────────────────────────
|
|
48
|
+
def _load(self) -> list[dict]:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
51
|
+
return data if isinstance(data, list) else []
|
|
52
|
+
except Exception:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def _persist(self):
|
|
56
|
+
try:
|
|
57
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._path.write_text(json.dumps(self._items), encoding="utf-8")
|
|
59
|
+
except Exception:
|
|
60
|
+
pass # best-effort — the ring keeps working in memory
|
|
61
|
+
|
|
62
|
+
# ── core API ─────────────────────────────────────────────────────────
|
|
63
|
+
def publish(self, kind: str, title: str, body: str = "",
|
|
64
|
+
run_id: str | None = None, data: dict | None = None) -> dict:
|
|
65
|
+
item = {
|
|
66
|
+
"id": self._next_id,
|
|
67
|
+
"ts": time.time(),
|
|
68
|
+
"kind": kind, # human_input | run_completed | run_failed
|
|
69
|
+
"title": title[:200],
|
|
70
|
+
"body": (body or "")[:500],
|
|
71
|
+
"run_id": run_id,
|
|
72
|
+
"data": data or {},
|
|
73
|
+
}
|
|
74
|
+
self._next_id += 1
|
|
75
|
+
self._items.append(item)
|
|
76
|
+
if len(self._items) > self._capacity:
|
|
77
|
+
self._items = self._items[-self._capacity:]
|
|
78
|
+
self._persist()
|
|
79
|
+
for queue in list(self._subs):
|
|
80
|
+
queue.put_nowait(item)
|
|
81
|
+
self._dispatch_messaging(item)
|
|
82
|
+
return item
|
|
83
|
+
|
|
84
|
+
def list(self, after_id: int = 0) -> list[dict]:
|
|
85
|
+
return [i for i in self._items if i["id"] > after_id]
|
|
86
|
+
|
|
87
|
+
def last_id(self) -> int:
|
|
88
|
+
return self._next_id - 1
|
|
89
|
+
|
|
90
|
+
def subscribe(self) -> asyncio.Queue:
|
|
91
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
92
|
+
self._subs.add(queue)
|
|
93
|
+
return queue
|
|
94
|
+
|
|
95
|
+
def unsubscribe(self, queue: asyncio.Queue):
|
|
96
|
+
self._subs.discard(queue)
|
|
97
|
+
|
|
98
|
+
def configure(self, server_module):
|
|
99
|
+
"""Give the hub access to the messaging manager (server startup)."""
|
|
100
|
+
self._server_module = server_module
|
|
101
|
+
|
|
102
|
+
# ── engine event observer (runner pump) ──────────────────────────────
|
|
103
|
+
def observe_run_event(self, run_id: str, event: dict):
|
|
104
|
+
etype = event.get("type", "")
|
|
105
|
+
if etype == "orchestration_start":
|
|
106
|
+
self._orch_names[run_id] = (
|
|
107
|
+
event.get("orchestration_name") or event.get("orchestration_id") or run_id
|
|
108
|
+
)
|
|
109
|
+
self._terminal_notified.discard(run_id)
|
|
110
|
+
elif etype == "step_start":
|
|
111
|
+
# Any forward progress re-arms terminal notification (a resumed
|
|
112
|
+
# run emits no orchestration_start) and means any pending
|
|
113
|
+
# human-input request for this run was answered.
|
|
114
|
+
self._terminal_notified.discard(run_id)
|
|
115
|
+
self._resolve_human_input(run_id)
|
|
116
|
+
elif etype == "human_input_required":
|
|
117
|
+
name = self._orch_name(run_id)
|
|
118
|
+
self.publish(
|
|
119
|
+
"human_input", f"{name} needs your input",
|
|
120
|
+
body=str(event.get("prompt") or ""),
|
|
121
|
+
run_id=run_id,
|
|
122
|
+
data={"step_id": event.get("orch_step_id")},
|
|
123
|
+
)
|
|
124
|
+
elif etype == "orchestration_complete":
|
|
125
|
+
self._resolve_human_input(run_id)
|
|
126
|
+
if run_id in self._terminal_notified:
|
|
127
|
+
return
|
|
128
|
+
self._terminal_notified.add(run_id)
|
|
129
|
+
name = self._orch_name(run_id)
|
|
130
|
+
status = event.get("status", "completed")
|
|
131
|
+
if status == "completed":
|
|
132
|
+
self.publish("run_completed", f"{name} completed", run_id=run_id)
|
|
133
|
+
else:
|
|
134
|
+
self.publish("run_failed", f"{name} {status}", run_id=run_id)
|
|
135
|
+
elif etype == "orchestration_error":
|
|
136
|
+
self._resolve_human_input(run_id)
|
|
137
|
+
if run_id in self._terminal_notified:
|
|
138
|
+
return
|
|
139
|
+
self._terminal_notified.add(run_id)
|
|
140
|
+
name = self._orch_name(run_id)
|
|
141
|
+
self.publish("run_failed", f"{name} failed",
|
|
142
|
+
body=str(event.get("error") or ""), run_id=run_id)
|
|
143
|
+
elif etype == "run_stream_end" and not event.get("paused"):
|
|
144
|
+
self._orch_names.pop(run_id, None)
|
|
145
|
+
|
|
146
|
+
def _resolve_human_input(self, run_id: str):
|
|
147
|
+
"""Mark this run's human-input notifications answered. The updated
|
|
148
|
+
items (same id, resolved=true) are re-pushed to live subscribers;
|
|
149
|
+
clients upsert by id so the bell stops showing them as actionable."""
|
|
150
|
+
updated = [
|
|
151
|
+
item for item in self._items
|
|
152
|
+
if item["run_id"] == run_id and item["kind"] == "human_input"
|
|
153
|
+
and not item.get("resolved")
|
|
154
|
+
]
|
|
155
|
+
if not updated:
|
|
156
|
+
return
|
|
157
|
+
for item in updated:
|
|
158
|
+
item["resolved"] = True
|
|
159
|
+
self._persist()
|
|
160
|
+
for queue in list(self._subs):
|
|
161
|
+
for item in updated:
|
|
162
|
+
queue.put_nowait(item)
|
|
163
|
+
|
|
164
|
+
def _orch_name(self, run_id: str) -> str:
|
|
165
|
+
"""Cached name, with a checkpoint fallback for resumed runs (which
|
|
166
|
+
emit no orchestration_start)."""
|
|
167
|
+
name = self._orch_names.get(run_id)
|
|
168
|
+
if name:
|
|
169
|
+
return name
|
|
170
|
+
try:
|
|
171
|
+
from core.orchestration.state import SharedState
|
|
172
|
+
from core.routes.orchestrations import load_orchestrations
|
|
173
|
+
orch_id = SharedState.restore(run_id).run.orchestration_id
|
|
174
|
+
orch = next((o for o in load_orchestrations() if o["id"] == orch_id), None)
|
|
175
|
+
name = (orch or {}).get("name") or orch_id
|
|
176
|
+
except Exception:
|
|
177
|
+
name = "Orchestration"
|
|
178
|
+
self._orch_names[run_id] = name
|
|
179
|
+
return name
|
|
180
|
+
|
|
181
|
+
# ── startup: reconstruct missed human-input notifications ────────────
|
|
182
|
+
def reconstruct_missed(self, limit: int = 100):
|
|
183
|
+
"""Re-publish 'needs your input' for runs still paused on a human step
|
|
184
|
+
(the server may have restarted since the pause fired)."""
|
|
185
|
+
try:
|
|
186
|
+
from core.orchestration.state import SharedState
|
|
187
|
+
# Only an *unresolved* item counts as already-notified: a run that
|
|
188
|
+
# paused again at a later step deserves a fresh notification.
|
|
189
|
+
notified_runs = {
|
|
190
|
+
i["run_id"] for i in self._items
|
|
191
|
+
if i["kind"] == "human_input" and not i.get("resolved")
|
|
192
|
+
}
|
|
193
|
+
paused_waiting: set[str] = set()
|
|
194
|
+
for summary in SharedState.list_runs(limit=limit):
|
|
195
|
+
if summary.get("status") != "paused":
|
|
196
|
+
continue
|
|
197
|
+
run_id = summary.get("run_id") or ""
|
|
198
|
+
try:
|
|
199
|
+
run = SharedState.restore(run_id).run
|
|
200
|
+
except Exception:
|
|
201
|
+
continue
|
|
202
|
+
if not run.waiting_for_human:
|
|
203
|
+
continue
|
|
204
|
+
paused_waiting.add(run_id)
|
|
205
|
+
if run_id not in notified_runs:
|
|
206
|
+
self.publish(
|
|
207
|
+
"human_input",
|
|
208
|
+
f"{self._orch_name(run_id)} needs your input",
|
|
209
|
+
body=str(run.human_prompt or ""),
|
|
210
|
+
run_id=run_id,
|
|
211
|
+
)
|
|
212
|
+
# Inverse sweep: unresolved items whose run is no longer waiting
|
|
213
|
+
# (answered/failed/swept while we were down) get resolved.
|
|
214
|
+
for run_id in notified_runs - paused_waiting:
|
|
215
|
+
self._resolve_human_input(run_id)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f"Warning: notification reconstruction failed: {e}", flush=True)
|
|
218
|
+
|
|
219
|
+
# ── optional messaging dispatch ──────────────────────────────────────
|
|
220
|
+
def _dispatch_messaging(self, item: dict):
|
|
221
|
+
try:
|
|
222
|
+
loop = asyncio.get_running_loop()
|
|
223
|
+
except RuntimeError:
|
|
224
|
+
return
|
|
225
|
+
loop.create_task(self._send_messaging(item))
|
|
226
|
+
|
|
227
|
+
async def _send_messaging(self, item: dict):
|
|
228
|
+
try:
|
|
229
|
+
from core.config import load_settings
|
|
230
|
+
channel_id = (load_settings() or {}).get("run_notification_channel_id") or ""
|
|
231
|
+
if not channel_id:
|
|
232
|
+
return
|
|
233
|
+
manager = getattr(self._server_module, "messaging_manager", None)
|
|
234
|
+
adapter = manager._adapters.get(channel_id) if manager else None
|
|
235
|
+
if adapter is None:
|
|
236
|
+
return
|
|
237
|
+
from core.messaging import store as channel_store
|
|
238
|
+
channel = channel_store.get_channel(channel_id) or {}
|
|
239
|
+
chat_id = channel.get("notify_chat_id") or getattr(adapter, "_last_chat_id", None)
|
|
240
|
+
if not chat_id:
|
|
241
|
+
return
|
|
242
|
+
icon = {"human_input": "⏸", "run_completed": "✅", "run_failed": "❌"}.get(item["kind"], "🔔")
|
|
243
|
+
text = f"{icon} {item['title']}"
|
|
244
|
+
if item.get("body"):
|
|
245
|
+
text += f"\n{item['body']}"
|
|
246
|
+
await adapter.send_message(chat_id, text)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
print(f"Warning: messaging notification failed: {e}", flush=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
hub = NotificationHub()
|
|
@@ -78,6 +78,14 @@ class OrchestrationEngine:
|
|
|
78
78
|
)
|
|
79
79
|
state = SharedState(run)
|
|
80
80
|
|
|
81
|
+
# Initial checkpoint: make the run visible to /runs listings and
|
|
82
|
+
# status polling from second zero. Without it the run file only
|
|
83
|
+
# appears at the first step boundary, so a run inside a long first
|
|
84
|
+
# step is invisible to the active-runs UI (and a crash there leaves
|
|
85
|
+
# no file to resume from). The startup zombie sweep handles these
|
|
86
|
+
# like any other interrupted "running" checkpoint.
|
|
87
|
+
state.checkpoint()
|
|
88
|
+
|
|
81
89
|
self.logger = OrchestrationLogger(
|
|
82
90
|
run_id=run_id,
|
|
83
91
|
orchestration_id=self.orch.id,
|
|
@@ -356,6 +364,13 @@ class OrchestrationEngine:
|
|
|
356
364
|
For failed runs: the failed step's history entry is removed so it re-executes.
|
|
357
365
|
For cancelled runs: resumes from current_step_id (which was never started).
|
|
358
366
|
All state accumulated by prior steps is preserved.
|
|
367
|
+
|
|
368
|
+
Mid-node continuity: agent/loop/parallel executors checkpoint private
|
|
369
|
+
progress keys in shared_state (_step_progress_*, _loop_progress_*,
|
|
370
|
+
_parallel_progress_*) as work completes, so the re-executed node
|
|
371
|
+
continues from its last turn/iteration/branch instead of starting
|
|
372
|
+
from scratch. The seeding happens inside the executors — this method
|
|
373
|
+
just replays the node.
|
|
359
374
|
"""
|
|
360
375
|
from .state import SharedState as SS, _cancelled_run_ids
|
|
361
376
|
|
|
@@ -395,6 +410,11 @@ class OrchestrationEngine:
|
|
|
395
410
|
run.status = "running"
|
|
396
411
|
|
|
397
412
|
state = SharedState(run)
|
|
413
|
+
# Persist "running" before the first step so the run reappears in the
|
|
414
|
+
# active-runs listing immediately. Without this the checkpoint still
|
|
415
|
+
# reads failed/cancelled until the first step boundary, and a resumed
|
|
416
|
+
# run looks dead for as long as its first step takes.
|
|
417
|
+
state.checkpoint()
|
|
398
418
|
async for event in engine._execute_loop(run, state):
|
|
399
419
|
yield event
|
|
400
420
|
|
|
@@ -466,6 +486,9 @@ class OrchestrationEngine:
|
|
|
466
486
|
|
|
467
487
|
print(f"[engine.resume] 🚀 entering _execute_loop with current_step_id={run.current_step_id!r} status={run.status}", flush=True)
|
|
468
488
|
state = SharedState(run)
|
|
489
|
+
# Persist "running" + the cleared human flag before the first step, so
|
|
490
|
+
# the run stops showing as "needs input" the moment it resumes.
|
|
491
|
+
state.checkpoint()
|
|
469
492
|
async for event in engine._execute_loop(run, state):
|
|
470
493
|
yield event
|
|
471
494
|
print(f"[engine.resume] 🏁 _execute_loop finished, run.status={run.status} run.current_step_id={run.current_step_id!r}", flush=True)
|
|
@@ -581,6 +604,8 @@ class OrchestrationEngine:
|
|
|
581
604
|
run.current_step_id = next_id
|
|
582
605
|
|
|
583
606
|
state = SharedState(run)
|
|
607
|
+
# Same as resume(): make the un-paused state visible before step one.
|
|
608
|
+
state.checkpoint()
|
|
584
609
|
async for event in engine._execute_loop(run, state):
|
|
585
610
|
yield event
|
|
586
611
|
|