diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index cd4fcd30..45bf929d 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -168,7 +168,14 @@ async def signin_activate(body: SigninActivateRequest): @auth.router.post("/signout") async def signout(): - """Revoke the cloud-side bearer + clear local identity state.""" + """Revoke the cloud-side bearer + clear local identity state. + + Also stops every in-flight agent session so any 9Router subprocess + that captured the now-revoked bearer at spawn time can't keep using + it. Without this, a signed-out user's old chat tabs would keep + making /v1/messages calls with a token the cloud has revoked, + surfacing as 401s in the agent UI ("Invalid bearer token"). + """ settings_obj = load_settings() bearer = getattr(settings_obj, "openswarm_bearer_token", None) proxy = _proxy_url() @@ -184,6 +191,25 @@ async def signout(): # the cloud token is invalidated lazily on next use anyway. logger.warning("cloud signout failed (clearing local anyway): %s", e) + # Stop every running agent session BEFORE clearing local state. The + # next message in any tab will then re-launch the session, which + # re-reads settings (now empty) and re-spawns 9Router subprocesses + # with whatever the user re-signs-in with — or with own_key mode + # if they don't sign back in. Best-effort: failures here shouldn't + # block the sign-out itself. + try: + from backend.apps.agents.agent_manager import agent_manager + running = list(agent_manager.tasks.keys()) + for session_id in running: + try: + await agent_manager.stop_agent(session_id) + except Exception as e: + logger.warning("signout: stop_agent(%s) failed: %s", session_id, e) + if running: + logger.info("signout: stopped %d in-flight agent session(s)", len(running)) + except Exception as e: + logger.warning("signout: agent shutdown skipped: %s", e) + settings_obj.user_id = None settings_obj.user_email = None settings_obj.signin_method = None diff --git a/backend/auth.py b/backend/auth.py index f93a0925..27b7ec54 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -99,6 +99,14 @@ _AUTH_EXEMPT_EXACT = { # has no way to inject our bearer token; the install_id check inside # the handler is what binds the request to this user. "/api/tools/oauth/cloud-claim", + # Bearer-handoff endpoints called by api.openswarm.com's success page + # AFTER Stripe checkout / Google sign-in / magic-link sign-in. The + # request POSTs the just-minted cloud bearer; the handler then re- + # validates it against the cloud (/api/me or /api/auth/signin-activate). + # The browser has no way to attach our per-install token here — the + # cloud-validated bearer in the body is the actual auth mechanism. + "/api/subscription/activate", + "/api/auth/signin-activate", "/api/version", } diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 76afa7f9..513ee68c 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -191,6 +191,19 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = }) .catch(() => { /* offline — next launch will reconcile */ }); }, [dispatch]); + + // Refetch settings when the window regains focus. Catches every out-of- + // band settings mutation that doesn't come through a renderer-dispatched + // thunk: Stripe checkout's bearer-handoff page POSTing /api/subscription/ + // activate, the new sign-in flow's bearer-handoff POSTing /api/auth/ + // signin-activate, manual ~/.openswarm/settings.json edits, etc. Throttled + // by the browser's natural focus cadence (one refetch per Cmd-Tab back). + useEffect(() => { + const onFocus = () => { dispatch(fetchSettings()); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [dispatch]); + useEffect(() => { if (loaded) setThemeMode(theme as 'light' | 'dark'); }, [loaded, theme, setThemeMode]); @@ -220,6 +233,7 @@ interface IdentityStatus { } const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const dispatch = useAppDispatch(); const settings = useAppSelector((s) => s.settings.data); const settingsLoaded = useAppSelector((s) => s.settings.loaded); const [status, setStatus] = useState(null); @@ -249,6 +263,19 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) return () => { cancelled = true; }; }, [settingsLoaded, alreadySignedIn]); + // While the gate is showing, poll settings every 2s so the moment the + // sign-in flow completes (browser POSTs /api/auth/signin-activate, local + // backend persists user_id to settings.json), we re-read settings and + // the gate auto-dismisses without the user clicking anything. Cheap — + // /api/settings is a static file read on the local backend. Stops as + // soon as the user is authed or the user has skipped. + useEffect(() => { + if (!settingsLoaded || alreadySignedIn) return; + if (status?.authed) return; + const id = setInterval(() => { dispatch(fetchSettings()); }, 2000); + return () => clearInterval(id); + }, [dispatch, settingsLoaded, alreadySignedIn, status?.authed]); + // Read the persisted "remind me later" timestamp from localStorage so // soft-gate skip survives reloads but doesn't bloat AppSettings. useEffect(() => {