mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[eric] actions: sign-in button + live signed-in status for reddit/x/tiktok browser-login MCPs
This commit is contained in:
@@ -498,6 +498,15 @@ async def browser_agent_run(request: Request):
|
||||
# Allowlisted social platforms whose own-session MCP shims may borrow partition cookies. The allowlist is the real scope: even an authenticated localhost caller can only ever read these sites' cookies, never an arbitrary domain, so this can't become a general cookie-theft oracle.
|
||||
P_SESSION_COOKIE_DOMAINS = {"reddit.com", "x.com", "twitter.com", "tiktok.com"}
|
||||
|
||||
# Per-domain "you're actually logged in" cookie(s). Presence of any = signed in; we check the
|
||||
# real session cookie, not just any cookie, so a logged-out visit doesn't read as connected.
|
||||
P_SESSION_AUTH_COOKIES = {
|
||||
"reddit.com": ("reddit_session", "token_v2"),
|
||||
"x.com": ("auth_token",),
|
||||
"twitter.com": ("auth_token",),
|
||||
"tiktok.com": ("sessionid", "sessionid_ss"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/browser-session/cookies")
|
||||
async def browser_session_cookies(domain: str = ""):
|
||||
@@ -517,6 +526,26 @@ async def browser_session_cookies(domain: str = ""):
|
||||
return JSONResponse({"cookies": result.get("cookies", []), "userAgent": result.get("userAgent", "")})
|
||||
|
||||
|
||||
@app.get("/api/browser-session/status")
|
||||
async def browser_session_status(domain: str = ""):
|
||||
"""Report whether the user is signed in to a vetted platform (has its real session cookie).
|
||||
|
||||
Drives the Actions-page 'Signed in / Not signed in' indicator for the session-borrow MCPs.
|
||||
Same walls as the cookie bridge (auth middleware + allowlist); returns only a boolean, never
|
||||
the cookies themselves.
|
||||
"""
|
||||
d = (domain or "").lower().strip().lstrip(".")
|
||||
if d not in P_SESSION_COOKIE_DOMAINS:
|
||||
return JSONResponse({"error": f"domain not allowed: {d or '(empty)'}", "connected": False}, status_code=400)
|
||||
rid = uuid4().hex
|
||||
result = await ws_manager.send_browser_command(rid, "get_session_cookies", "", {"domain": d})
|
||||
if result.get("error"):
|
||||
return JSONResponse({"connected": False, "error": result["error"]})
|
||||
wanted = P_SESSION_AUTH_COOKIES.get(d, ())
|
||||
names = {c.get("name") for c in result.get("cookies", []) if c.get("value")}
|
||||
return JSONResponse({"connected": any(n in names for n in wanted), "domain": d})
|
||||
|
||||
|
||||
@app.post("/api/browser-session/action")
|
||||
async def browser_session_action(request: Request):
|
||||
"""Drive a vetted platform's own live browser card (navigate + JS) for its MCP shim's writes.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { Integration } from '../integrations';
|
||||
|
||||
type Status = 'unknown' | 'connected' | 'disconnected';
|
||||
const POLL_MS = 5000;
|
||||
const MAX_POLLS = 24;
|
||||
|
||||
// Bare allowlist domain from the login URL (x.com, reddit.com, tiktok.com); www. is stripped so it matches the cookie bridge's allowlist.
|
||||
function sessionDomain(loginUrl: string | undefined): string {
|
||||
if (!loginUrl) return '';
|
||||
try {
|
||||
return new URL(loginUrl).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ig: Integration;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
// "Sign in" affordance for the session-borrow MCPs (reddit/x/tiktok): opens the site's login in a
|
||||
// browser card (via the app-wide anchor handler) and shows a live signed-in indicator driven by the
|
||||
// cookie bridge. The sign-in is a real <a href>, so AppShell's document click handler navigates to a
|
||||
// dashboard and opens the card, no duplicated open logic here.
|
||||
const BrowserLoginConnect: React.FC<Props> = ({ ig, isDisabled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [status, setStatus] = useState<Status>('unknown');
|
||||
const domain = sessionDomain(ig.loginUrl);
|
||||
const alive = useRef(true);
|
||||
const poll = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const check = useCallback(async (): Promise<boolean> => {
|
||||
if (!domain) return false;
|
||||
try {
|
||||
// t= busts the renderer's 1s GET cache so the signed-in state is always live, not a stale hit.
|
||||
const res = await fetch(`${API_BASE}/browser-session/status?domain=${encodeURIComponent(domain)}&t=${Date.now()}`);
|
||||
const data = await res.json();
|
||||
const connected = !!data.connected;
|
||||
if (alive.current) setStatus(connected ? 'connected' : 'disconnected');
|
||||
return connected;
|
||||
} catch {
|
||||
if (alive.current) setStatus('disconnected');
|
||||
return false;
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
let n = 0;
|
||||
const stop = () => { if (poll.current) { clearInterval(poll.current); poll.current = null; } };
|
||||
check();
|
||||
poll.current = setInterval(async () => {
|
||||
n += 1;
|
||||
const connected = await check();
|
||||
if (connected || n >= MAX_POLLS) stop();
|
||||
}, POLL_MS);
|
||||
return () => { alive.current = false; stop(); };
|
||||
}, [check]);
|
||||
|
||||
if (isDisabled || !domain) return null;
|
||||
|
||||
if (status === 'connected') {
|
||||
return (
|
||||
<Tooltip title="Re-check sign-in">
|
||||
<Chip
|
||||
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
|
||||
label="Signed in"
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); check(); }}
|
||||
sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
<Tooltip title={ig.connectInstructions || ''}>
|
||||
<Button
|
||||
component="a"
|
||||
href={ig.loginUrl}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
|
||||
>
|
||||
{ig.connectLabel || 'Sign in'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Re-check sign-in">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); check(); }} sx={{ color: c.text.ghost }}>
|
||||
<RefreshIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserLoginConnect;
|
||||
@@ -7,6 +7,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { Integration } from '../integrations';
|
||||
import BrowserLoginConnect from './BrowserLoginConnect';
|
||||
|
||||
interface CustomToolConnectProps {
|
||||
tool: ToolDefinition;
|
||||
@@ -30,6 +31,9 @@ const CustomToolConnect: React.FC<CustomToolConnectProps> = ({
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<>
|
||||
{ig?.authType === 'browser_login' && (
|
||||
<BrowserLoginConnect ig={ig} isDisabled={isDisabled} />
|
||||
)}
|
||||
{!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && (tool.auth_status !== 'connected' || ig?.id === 'discord') && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -63,7 +67,7 @@ const CustomToolConnect: React.FC<CustomToolConnectProps> = ({
|
||||
{ig.connectLabel || 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig && tool.auth_status === 'connected' && (
|
||||
{!isDisabled && ig && ig.authType !== 'browser_login' && tool.auth_status === 'connected' && (
|
||||
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' ? 'Disconnect' : ''}>
|
||||
<Chip
|
||||
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface Integration {
|
||||
credentialFields?: CredentialField[];
|
||||
connectLabel?: string;
|
||||
connectInstructions?: string;
|
||||
authType?: 'none' | 'oauth2' | 'env_vars' | 'device_code';
|
||||
authType?: 'none' | 'oauth2' | 'env_vars' | 'device_code' | 'browser_login';
|
||||
loginUrl?: string;
|
||||
}
|
||||
|
||||
export const INTEGRATIONS: Integration[] = [
|
||||
@@ -29,6 +30,9 @@ export const INTEGRATIONS: Integration[] = [
|
||||
mcp_config: { type: 'stdio', command: 'python', args: ['-m', 'backend.apps.x_mcp_shim'] },
|
||||
color: '#000000',
|
||||
website: 'https://x.com',
|
||||
authType: 'browser_login',
|
||||
connectLabel: 'Sign in to X',
|
||||
loginUrl: 'https://x.com/i/flow/login',
|
||||
connectInstructions: 'Uses your own X account: open x.com in an OpenSwarm browser card and sign in once. Nothing is stored, the integration borrows your live session per request and paces itself to stay within human limits.',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||
@@ -43,6 +47,9 @@ export const INTEGRATIONS: Integration[] = [
|
||||
mcp_config: { type: 'stdio', command: 'python', args: ['-m', 'backend.apps.tiktok_mcp_shim'] },
|
||||
color: '#FE2C55',
|
||||
website: 'https://www.tiktok.com',
|
||||
authType: 'browser_login',
|
||||
connectLabel: 'Sign in to TikTok',
|
||||
loginUrl: 'https://www.tiktok.com/login',
|
||||
connectInstructions: 'Uses your own TikTok account: open tiktok.com in an OpenSwarm browser card and sign in once. Nothing is stored. Note: TikTok signs every request, so signed writes and uploads route to the OpenSwarm browser agent (also free, using your real session).',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||
@@ -57,6 +64,9 @@ export const INTEGRATIONS: Integration[] = [
|
||||
mcp_config: { type: 'stdio', command: 'python', args: ['-m', 'backend.apps.reddit_mcp_shim'] },
|
||||
color: '#FF4500',
|
||||
website: 'https://www.reddit.com',
|
||||
authType: 'browser_login',
|
||||
connectLabel: 'Sign in to Reddit',
|
||||
loginUrl: 'https://www.reddit.com/login',
|
||||
connectInstructions: 'Uses your own Reddit account: open reddit.com in an OpenSwarm browser card and sign in once. Nothing is stored, the integration borrows your live session per request and paces itself to stay within human limits.',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
|
||||
Reference in New Issue
Block a user