diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index c26130a1..562a2fdf 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -110,6 +110,16 @@ jobs: $ErrorActionPreference = 'Stop' $shouldPublish = ($env:GITHUB_EVENT_NAME -eq 'push') -or ` ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:PUBLISH_INPUT -eq 'true') + # electron-builder auto-detects prerelease from semver suffix in electron/package.json, + # but EP_PRE_RELEASE forces the GitHub Releases publisher to mark it Pre-release even + # when the runner's environment differs from local. Set it whenever the version has a "-" suffix. + $version = (Get-Content electron/package.json | ConvertFrom-Json).version + if ($version -match '-') { + $env:EP_PRE_RELEASE = 'true' + Write-Host "Version $version is EXPERIMENTAL; setting EP_PRE_RELEASE=true" + } else { + Write-Host "Version $version is STABLE" + } if ($shouldPublish) { Write-Host "Build mode: PUBLISH" pwsh -NoProfile -File scripts\build-app-win.ps1 -Publish diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index b1981a6a..5ea13999 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -676,8 +676,10 @@ class AgentManager: "do not say 'I'll schedule it', do not call any scheduling tool." ) sections.append( - "2. After MCPActivate returns, end the turn; a follow-up turn fires " - "automatically with the new tools available." + "2. After MCPActivate returns, do NOT make any more tool calls in " + "this turn. The transport snapshot is locked; calls against the " + "new server would hit a stale schema list. The turn ends " + "automatically and a hidden continuation fires with the new tools." ) sections.append( "3. Don't ask 'should I activate X?' first; MCPActivate already " @@ -1568,6 +1570,32 @@ class AgentManager: "session_id": session_id, "message": result_msg.model_dump(mode="json"), }) + + # Hard-stop the turn after a successful MCPActivate. The CLI snapshots + # mcp_servers at transport launch, so any mid-turn tool calls against + # the just-activated server would hit a stale schema list and the + # model would hallucinate names (e.g. "Notion: Searchpages" instead + # of "mcp__notion__search"). The auto-continuation hook below + # (pending_continuation) fires a hidden follow-up turn with a + # fresh-session restart, so by stopping here we get a clean + # transport relaunch with the new server's tools loaded. Without + # this stop, success depended on the model voluntarily ending the + # turn after reading the activation tool result; failure mode was + # the 10x-hallucinated-tool-call spiral. + if ( + hook_tool_name == "mcp__openswarm-mcp-meta__MCPActivate" + and isinstance(content, str) + and content.startswith("Activated `") + and getattr(session, "pending_continuation", False) + ): + return { + "continue_": False, + "stopReason": ( + "MCP server activated; ending turn so its tool schemas " + "load into a fresh transport. A hidden continuation " + "turn fires automatically." + ), + } return {"continue_": True} try: diff --git a/backend/apps/agents/mcp_meta_server.py b/backend/apps/agents/mcp_meta_server.py index 1be1fbd1..f4672136 100644 --- a/backend/apps/agents/mcp_meta_server.py +++ b/backend/apps/agents/mcp_meta_server.py @@ -178,8 +178,10 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: "type": "text", "text": ( f"Activated `{server_name}`. Its tools (`mcp__{server_name}__*`) " - f"will be callable on the NEXT turn. End this turn now and the user's " - f"next message will see the new tools." + f"are NOT callable in this turn; the transport snapshot is " + f"already locked. This turn will end automatically and a " + f"hidden continuation turn will fire with the new tools " + f"loaded. Do not attempt any other tool call now." ), }], } diff --git a/backend/apps/google_workspace_mcp_shim/__init__.py b/backend/apps/google_workspace_mcp_shim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/google_workspace_mcp_shim/run.py b/backend/apps/google_workspace_mcp_shim/run.py new file mode 100644 index 00000000..189b107f --- /dev/null +++ b/backend/apps/google_workspace_mcp_shim/run.py @@ -0,0 +1,57 @@ +"""Run google-workspace-mcp's stdio worker with token refresh redirected +through our local proxy instead of directly to oauth2.googleapis.com. + +google_workspace_mcp.auth.gauth.get_credentials() hardcodes +token_uri="https://oauth2.googleapis.com/token" and refreshes with +whatever GOOGLE_WORKSPACE_CLIENT_ID/SECRET are in env on every API call. +OAuth runs through a rotation pool in openswarm-cloud, so the +refresh_token is bound to whichever pool slot minted it, not the single +client baked into the DMG. Refresh directly against Google with the +wrong client returns unauthorized_client. + +This wrapper monkey-patches gauth.get_credentials before the worker +imports its tool modules, pointing token_uri at GOOGLE_WORKSPACE_TOKEN_URI +(our local proxy at /api/tools/google-oauth-token, which forwards refresh +requests to the cloud's pool-aware /api/oauth/google/refresh). +CLIENT_ID/SECRET become unused placeholders. +""" + +import functools +import os + +import google_workspace_mcp.auth.gauth as gauth +from google.oauth2.credentials import Credentials + + +@functools.lru_cache(maxsize=1) +def _patched_get_credentials(): + refresh_token = os.environ.get("GOOGLE_WORKSPACE_REFRESH_TOKEN") + if not refresh_token: + raise ValueError("GOOGLE_WORKSPACE_REFRESH_TOKEN env var is required") + return Credentials( + token=None, + refresh_token=refresh_token, + token_uri=os.environ.get( + "GOOGLE_WORKSPACE_TOKEN_URI", + "https://oauth2.googleapis.com/token", + ), + client_id=os.environ.get("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy"), + client_secret=os.environ.get("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy"), + ) + + +gauth.get_credentials = _patched_get_credentials + + +from google_workspace_mcp import __main__ as _gw_main # noqa: E402,F401 +from google_workspace_mcp.app import mcp # noqa: E402 + + +if __name__ == "__main__": + # Upstream google_workspace_mcp.__main__.main() wraps a synchronous + # mcp.run() in asyncio.run() which throws "a coroutine was expected, + # got None" against current FastMCP. Skip it and invoke FastMCP's + # stdio loop directly. The `_gw_main` import above is what actually + # registers every tool/prompt/resource module against the shared + # `mcp` instance via its top-level imports. + mcp.run("stdio") diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 928d7f8a..21576dbc 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -53,6 +53,7 @@ class AppSettings(BaseModel): expand_new_chats_in_dashboard: bool = False auto_reveal_sub_agents: bool = True dev_mode: bool = False + allow_experimental_updates: bool = False claude_subscription_token: Optional[str] = None openai_subscription_token: Optional[str] = None gemini_subscription_token: Optional[str] = None diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 7a137744..783a2f6a 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -13,7 +13,7 @@ from urllib.parse import urlencode import httpx from dotenv import load_dotenv -from fastapi import HTTPException, Query +from fastapi import HTTPException, Query, Request, Response from fastapi.responses import HTMLResponse from pydantic import BaseModel from backend.config.Apps import SubApp @@ -291,21 +291,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"] if tool.oauth_tokens.get("refresh_token"): env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"] - # google_workspace_mcp's auth/gauth.py requires all three of - # CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN at startup and does - # its own token refresh per API call; it ignores any - # pre-refreshed access_token. v1.0.29's cloud-proxy migration - # closes the OAuth-flow secret exposure, but the MCP still - # needs the local secret here. v1.0.30 should either fork the - # MCP to point token_uri at our cloud refresh proxy, or replace - # it with a thin in-house Gmail/Drive/Calendar wrapper that - # consumes a pre-refreshed access_token. - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") - if client_id: - env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id - if client_secret: - env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret + # google_workspace_mcp's gauth.py hardcodes token_uri to + # https://oauth2.googleapis.com/token and refreshes using the + # local CLIENT_ID/SECRET on every API call. The OAuth flow + # itself runs through the cloud's rotation pool, so the + # refresh_token is bound to whichever pool slot minted it, + # not the single client baked into the DMG. Mismatch -> Google + # returns unauthorized_client. We point token_uri at a local + # proxy that forwards the refresh to our cloud's pool-aware + # /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become + # unused placeholders (gauth.py only validates non-empty). + _port = os.environ.get("OPENSWARM_PORT", "8324") + env["GOOGLE_WORKSPACE_TOKEN_URI"] = ( + f"http://127.0.0.1:{_port}/api/tools/google-oauth-token" + ) + env.setdefault("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy") + env.setdefault("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy") + + # Google Workspace MCP: redirect spawn through our shim that + # monkey-patches gauth.get_credentials before the worker registers + # tools, so token_uri points at our local proxy. Stays a stdio + # subprocess; google-workspace-mcp gets installed into uv's + # ephemeral env via --with, same way the upstream entry-point + # invocation used to do it. + if tool.name.lower() == "google workspace" and config.get("type") == "stdio": + shim_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "google_workspace_mcp_shim", + "run.py", + ) + config["command"] = "uv" + config["args"] = ["run", "--with", "google-workspace-mcp", "python", shim_path] # Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim). # We pass install_id + base URL via env so the shim subprocess doesn't @@ -1290,3 +1306,60 @@ async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]: async def refresh_hubspot_token(tool: ToolDefinition) -> Optional[str]: """Refresh an expired HubSpot OAuth access_token.""" return await _refresh_via_proxy("hubspot", tool, default_expiry=1800) + + +@tools_lib.router.post("/google-oauth-token") +async def google_oauth_token_proxy(request: Request): + """Local mimic of Google's OAuth2 token endpoint for the + google-workspace-mcp subprocess. + + google-workspace-mcp's google-auth library posts form-encoded + {grant_type, refresh_token, client_id, client_secret} on every + expired-token refresh. Because OAuth runs through a cloud-side + rotation pool, the local CLIENT_ID/SECRET don't match the pool slot + that minted the refresh_token, so a direct refresh against Google + returns unauthorized_client. We accept the form-encoded shape, + discard the (mismatched) local client creds, and forward the + refresh_token to api.openswarm.com/api/oauth/google/refresh which + walks the pool to find the issuing slot. The cloud's JSON envelope + is reshaped back to Google's native token-endpoint response so + google-auth keeps working transparently. + """ + form = await request.form() + grant_type = form.get("grant_type") or "" + refresh_token = form.get("refresh_token") or "" + if grant_type != "refresh_token" or not refresh_token: + return Response( + content='{"error":"unsupported_grant_type"}', + status_code=400, + media_type="application/json", + ) + try: + async with httpx.AsyncClient(timeout=15.0) as client: + upstream = await client.post( + f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/google/refresh", + json={"refresh_token": refresh_token}, + ) + except Exception as e: + return Response( + content=f'{{"error":"upstream_unreachable","error_description":"{e}"}}', + status_code=502, + media_type="application/json", + ) + if upstream.status_code != 200: + return Response( + content=upstream.text, + status_code=upstream.status_code, + media_type="application/json", + ) + tokens = (upstream.json() or {}).get("tokens") or {} + return Response( + content=json.dumps({ + "access_token": tokens.get("access_token", ""), + "expires_in": tokens.get("expires_in", 3600), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + }), + status_code=200, + media_type="application/json", + ) diff --git a/backend/auth.py b/backend/auth.py index 6e10e12d..154be5d4 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -179,6 +179,13 @@ _AUTH_EXEMPT_EXACT = { "/api/subscription/activate", "/api/auth/signin-activate", "/api/version", + # Local Google OAuth token-endpoint proxy: hit by the + # google-workspace-mcp subprocess we spawn. It doesn't (and can't + # easily) carry the install bearer in google-auth's refresh post. + # Localhost binding is the gate, and the route does nothing the + # public api.openswarm.com/api/oauth/google/refresh doesn't already + # do for any internet caller, so no new attack surface. + "/api/tools/google-oauth-token", } _AUTH_EXEMPT_PREFIX = ( diff --git a/electron/main.js b/electron/main.js index c53f27c7..2a3a826a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -580,6 +580,8 @@ function setupAutoUpdater() { // Download on detect, install on quit: OS can't replace a running .app/.exe. autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; + // Renderer pushes the user's experimental-updates setting via IPC right after settings load. + autoUpdater.allowPrerelease = false; autoUpdater.on('update-available', (info) => { console.log(`Update available: ${info.version}`); @@ -1082,6 +1084,20 @@ ipcMain.handle('download-update', async () => { } }); +ipcMain.handle('set-allow-prerelease', async (_e, value) => { + if (!autoUpdater) return { success: false, error: 'Updater not available' }; + const next = Boolean(value); + if (autoUpdater.allowPrerelease === next) return { success: true, changed: false }; + autoUpdater.allowPrerelease = next; + if (!isPackaged) return { success: true, changed: true }; + try { + await autoUpdater.checkForUpdates(); + } catch (err) { + return { success: false, changed: true, error: err?.message || String(err) }; + } + return { success: true, changed: true }; +}); + ipcMain.handle('install-update', async () => { if (!autoUpdater) return { installed: false, queued: false }; // Veto while workflow is in flight; lifecycle poller fires deferred install once active drains. diff --git a/electron/preload.js b/electron/preload.js index 7ed6ee0e..1885c91f 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -37,6 +37,7 @@ const { contextBridge, ipcRenderer } = require('electron'); checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('download-update'), installUpdate: () => ipcRenderer.invoke('install-update'), + setAllowPrerelease: (value) => ipcRenderer.invoke('set-allow-prerelease', value), onUpdateAvailable: (cb) => { const listener = (_event, info) => cb(info); diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index f7dd0110..d7ebc00f 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -206,6 +206,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = const { setMode: setThemeMode } = useThemeMode(); const theme = useAppSelector((s) => s.settings.data.theme); const loaded = useAppSelector((s) => s.settings.loaded); + const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates); useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); @@ -225,6 +226,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { if (loaded) setThemeMode(theme as 'light' | 'dark'); }, [loaded, theme, setThemeMode]); + + useEffect(() => { + if (!loaded) return; + (window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates); + }, [loaded, allowExperimentalUpdates]); return <>{children}; }; diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 6a3fc0dc..bc960204 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -1884,7 +1884,7 @@ const Settings: React.FC = () => { Advanced - + Developer mode Show transport details, environment variables, raw configs, and other technical metadata throughout the app. @@ -1899,6 +1899,21 @@ const Settings: React.FC = () => { /> + + + Experimental updates + Receive pre-release builds with new features earlier. These versions may be less stable than normal releases. + + setForm({ ...form, allow_experimental_updates: e.target.checked })} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary }, + }} + /> + + About diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index bc9fd973..750c77d3 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -55,6 +55,7 @@ export interface AppSettings { expand_new_chats_in_dashboard: boolean; auto_reveal_sub_agents: boolean; dev_mode: boolean; + allow_experimental_updates: boolean; /** Managed subscription state; surfaces only when user has subscribed via cloud. */ connection_mode?: 'own_key' | 'openswarm-pro'; openswarm_bearer_token?: string | null; @@ -119,6 +120,7 @@ const initialState: SettingsState = { expand_new_chats_in_dashboard: false, auto_reveal_sub_agents: true, dev_mode: false, + allow_experimental_updates: false, }, loading: false, loaded: false, diff --git a/publish.sh b/publish.sh index fccaa935..c5727222 100755 --- a/publish.sh +++ b/publish.sh @@ -1,5 +1,19 @@ #!/bin/bash # The comment above is shebang, DO NOT REMOVE +# +# Publishes a macOS build to GitHub Releases. Channel is auto-detected from +# the semver string in electron/package.json: +# +# Stable: "1.0.37" -> normal GitHub release +# Experimental: "1.0.37-exp.1" -> marked Pre-release on GitHub; only reaches +# users with "Experimental updates" enabled +# in Settings > Advanced. +# +# To promote an experimental release to stable: un-check "This is a pre-release" +# on the GitHub release page (native GitHub UI, no code change needed). +# +# Windows release: tag-triggered via .github/workflows/release-windows.yml, +# which performs the same auto-detection. PUBLISH_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")" if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' 's/\r//g' "$PUBLISH_ABSPATH" @@ -11,7 +25,18 @@ chmod +x "$PUBLISH_ABSPATH" PROJECT_ROOT="$(dirname "$PUBLISH_ABSPATH")" cd "$PROJECT_ROOT" -echo "Building and deploying to Firebase Hosting..." +# electron-builder auto-detects prerelease from semver suffix in electron/package.json +# (e.g. "1.0.37-exp.1" publishes as GitHub Pre-release; "1.0.37" publishes as stable). +# We also export EP_PRE_RELEASE for belt-and-suspenders so the GitHub Releases publisher +# can't accidentally promote an experimental build. +VERSION="$(node -p "require('./electron/package.json').version")" +if [[ "$VERSION" == *-* ]]; then + export EP_PRE_RELEASE=true + echo "==> Publishing EXPERIMENTAL release: v$VERSION (will be marked Pre-release on GitHub)" +else + echo "==> Publishing STABLE release: v$VERSION" +fi + bash scripts/build-app.sh --publish -cd - \ No newline at end of file +cd -