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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/backend/core/config.py +10 -0
  3. package/backend/core/internal_auth.py +68 -14
  4. package/backend/core/mcp_client.py +62 -1
  5. package/backend/core/models.py +4 -0
  6. package/backend/core/notifications.py +251 -0
  7. package/backend/core/orchestration/engine.py +25 -0
  8. package/backend/core/orchestration/journal.py +263 -0
  9. package/backend/core/orchestration/runner.py +53 -4
  10. package/backend/core/orchestration/state.py +35 -3
  11. package/backend/core/orchestration/steps.py +140 -4
  12. package/backend/core/react_engine.py +39 -3
  13. package/backend/core/routes/api_v1.py +26 -2
  14. package/backend/core/routes/data.py +6 -0
  15. package/backend/core/routes/notifications.py +65 -0
  16. package/backend/core/routes/orchestrations.py +195 -5
  17. package/backend/core/routes/tools.py +14 -0
  18. package/backend/core/scale/worker_server_module.py +22 -1
  19. package/backend/core/scheduler.py +8 -1
  20. package/backend/core/server.py +43 -0
  21. package/backend/requirements-messaging.lock +1 -1
  22. package/backend/requirements.lock +6 -6
  23. package/frontend-build/.next/BUILD_ID +1 -1
  24. package/frontend-build/.next/app-path-routes-manifest.json +2 -0
  25. package/frontend-build/.next/build-manifest.json +3 -3
  26. package/frontend-build/.next/prerender-manifest.json +3 -3
  27. package/frontend-build/.next/routes-manifest.json +14 -0
  28. package/frontend-build/.next/server/app/_global-error.html +1 -1
  29. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  30. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  31. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  33. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  34. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/_not-found/page.js +1 -1
  36. package/frontend-build/.next/server/app/_not-found/page.js.nft.json +1 -1
  37. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  38. package/frontend-build/.next/server/app/_not-found.html +1 -1
  39. package/frontend-build/.next/server/app/_not-found.rsc +17 -16
  40. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
  41. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  42. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
  43. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  44. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  45. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  46. package/frontend-build/.next/server/app/api/notifications/stream/route/app-paths-manifest.json +3 -0
  47. package/frontend-build/.next/server/app/api/notifications/stream/route/build-manifest.json +9 -0
  48. package/frontend-build/.next/server/app/api/notifications/stream/route/server-reference-manifest.json +4 -0
  49. package/frontend-build/.next/server/app/api/notifications/stream/route.js +6 -0
  50. package/frontend-build/.next/server/app/api/notifications/stream/route.js.map +5 -0
  51. package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -0
  52. package/frontend-build/.next/server/app/api/notifications/stream/route_client-reference-manifest.js +3 -0
  53. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/app-paths-manifest.json +3 -0
  54. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/build-manifest.json +9 -0
  55. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/server-reference-manifest.json +4 -0
  56. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +6 -0
  57. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.map +5 -0
  58. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -0
  59. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route_client-reference-manifest.js +3 -0
  60. package/frontend-build/.next/server/app/index.html +1 -1
  61. package/frontend-build/.next/server/app/index.rsc +19 -18
  62. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  63. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +19 -18
  64. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +4 -4
  65. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +6 -5
  66. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  67. package/frontend-build/.next/server/app/login/page.js +1 -1
  68. package/frontend-build/.next/server/app/login/page.js.nft.json +1 -1
  69. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  70. package/frontend-build/.next/server/app/login.html +1 -1
  71. package/frontend-build/.next/server/app/login.rsc +21 -20
  72. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +21 -20
  73. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +4 -4
  74. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +6 -5
  75. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  76. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  77. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +3 -3
  78. package/frontend-build/.next/server/app/page.js +1 -1
  79. package/frontend-build/.next/server/app/page.js.nft.json +1 -1
  80. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  81. package/frontend-build/.next/server/app/settings/[tab]/page.js +1 -1
  82. package/frontend-build/.next/server/app/settings/[tab]/page.js.nft.json +1 -1
  83. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  84. package/frontend-build/.next/server/app-paths-manifest.json +2 -0
  85. package/frontend-build/.next/server/chunks/0zjb_server_app_api_orchestrations_runs_[run_id]_events_stream_route_actions_0~mhgd~.js +3 -0
  86. package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +3 -0
  87. package/frontend-build/.next/server/chunks/[root-of-the-server]__0fa0k5_._.js +3 -0
  88. package/frontend-build/.next/server/chunks/_next-internal_server_app_api_notifications_stream_route_actions_109.jhp.js +3 -0
  89. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0.9mzsa._.js +3 -0
  90. package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__0wxl56w._.js → [root-of-the-server]__05u5pmw._.js} +2 -2
  91. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0fo_bex._.js +1 -1
  92. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0n2i~oj._.js +3 -0
  93. package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__10.-84t._.js → [root-of-the-server]__10jhy6u._.js} +2 -2
  94. package/frontend-build/.next/server/chunks/ssr/_0.27p4o._.js +4 -0
  95. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +15 -15
  96. package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_12ubua1._.js +3 -0
  97. package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
  98. package/frontend-build/.next/server/functions-config-manifest.json +6 -0
  99. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  100. package/frontend-build/.next/server/pages/404.html +1 -1
  101. package/frontend-build/.next/server/pages/500.html +1 -1
  102. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  103. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  104. package/frontend-build/.next/static/chunks/{16j4jub4qpyf1.js → 02hfac~emo77v.js} +15 -15
  105. package/frontend-build/.next/static/chunks/047the8jlj6un.css +1 -0
  106. package/frontend-build/.next/static/chunks/0e_m35~cfcbua.js +1 -0
  107. package/frontend-build/.next/static/chunks/{0cwgnxu88mzyi.js → 0j-zkf3m3ioxl.js} +1 -1
  108. package/frontend-build/.next/static/chunks/0jo8lflqyoi98.js +1 -0
  109. package/frontend-build/.next/static/chunks/0qsyb4u8ttd2i.js +1 -0
  110. package/frontend-build/.next/static/chunks/{0g7bk6lyj687k.js → 0~o3t9bm0eiky.js} +1 -1
  111. package/frontend-build/.next/static/chunks/11a8am5wux-qu.js +2 -0
  112. package/frontend-build/.next/static/chunks/16~0az_az7p4a.js +1 -0
  113. package/package.json +1 -1
  114. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0_30ktt._.js +0 -3
  115. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0rj.gz.._.js +0 -3
  116. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +0 -4
  117. package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_0__0o-2._.js +0 -3
  118. package/frontend-build/.next/static/chunks/03qf6ywkkwh~r.js +0 -1
  119. package/frontend-build/.next/static/chunks/0560sc9qpz__k.js +0 -2
  120. package/frontend-build/.next/static/chunks/0f8pg6_soqwt2.js +0 -1
  121. package/frontend-build/.next/static/chunks/0k0-nxx35rq9v.js +0 -1
  122. package/frontend-build/.next/static/chunks/0wtv8wcl1tn.d.js +0 -1
  123. package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +0 -1
  124. /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → sxSQKRDxOSif09kARGRDM}/_buildManifest.js +0 -0
  125. /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → sxSQKRDxOSif09kARGRDM}/_clientMiddlewareManifest.js +0 -0
  126. /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
@@ -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
- - /chat*, /auth/* → SKIP (direct backend routes, not under /api/)
14
- - /api/* → REQUIRE X-Synapse-Internal header
15
- - If SYNAPSE_INTERNAL_TOKEN is not set → permissive (backward compatible)
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
- # If no token configured, be permissive (local dev / backward compat)
36
- if not self.token:
37
- return await call_next(request)
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
- # Skip: external versioned API routes (v1, v2, ...) — they use API key
40
- # auth (require_api_key), not the internal frontend token. Match any
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
- # Skip: MCP OAuth callback — called by external OAuth providers, not frontend
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
- # Skip: FastAPI docs
86
+ # FastAPI docs.
50
87
  if path in ("/docs", "/openapi.json", "/redoc"):
51
88
  return await call_next(request)
52
89
 
53
- # Skip: non-API routes (chat, auth, health, websocket, etc.)
54
- if not path.startswith("/api/"):
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
- # This is an /api/* route — require internal token
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)
@@ -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