[eric] actions: sign-in button + live signed-in status for reddit/x/tiktok browser-login MCPs

This commit is contained in:
ciregenz
2026-06-30 19:41:13 -07:00
parent f24e6ecc52
commit f8620d62d0
4 changed files with 156 additions and 2 deletions
+29
View File
@@ -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 }} />}
+11 -1
View File
@@ -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">