From 30a46178d18cbd933f8899af8a11a628aed34d67 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 30 Jun 2026 01:17:38 +0530 Subject: [PATCH] feat(mini-apps): real scoped Composio bridge + GitHub Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 makes the Mini App bridge real instead of stubbed. - New composio:execute-tool and composio:search-tools IPC channels (shared schema + main handlers reusing the agent's execute path) - Bridge refactored to a scoped RPC: callAction/searchTools/isConnected/ connect, enforced against each app's declared scope at the host - Auth prompt: acting on a disconnected toolkit triggers Composio OAuth - GitHub Dashboard sample: track repos, view their 20 most-recent open PRs with descriptions, plus the authenticated user's profile + contribution graph — all live via the bridge - Twitter sample reverted to a local UI demo (X has no managed OAuth2) --- apps/x/apps/main/src/composio-handler.ts | 47 +++ apps/x/apps/main/src/ipc.ts | 6 + .../src/components/mini-app-frame.tsx | 84 +++-- .../src/mini-apps/apps/github-radar.ts | 314 ++++++++++++++++++ .../src/mini-apps/apps/twitter-client.ts | 28 +- .../x/apps/renderer/src/mini-apps/registry.ts | 3 +- apps/x/apps/renderer/src/mini-apps/runtime.ts | 27 +- apps/x/apps/renderer/src/mini-apps/types.ts | 22 +- apps/x/packages/shared/src/ipc.ts | 28 ++ 9 files changed, 496 insertions(+), 63 deletions(-) create mode 100644 apps/x/apps/renderer/src/mini-apps/apps/github-radar.ts diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 8fc4b754..5119b6ba 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -315,3 +315,50 @@ export async function listToolkits() { totalItems: filtered.length, }; } + +/** + * Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be + * connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin. + */ +export async function executeTool( + toolkitSlug: string, + toolSlug: string, + args?: Record, +): Promise<{ successful: boolean; data?: unknown; error?: string }> { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` }; + } + try { + const result = await composioClient.executeAction(toolSlug, { + connected_account_id: account.id, + user_id: 'rowboat-user', + version: 'latest', + arguments: args ?? {}, + }); + return { successful: result.successful, data: result.data, error: result.error ?? undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message); + return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` }; + } +} + +/** + * Search Composio tools within a toolkit so a Mini App can discover the right + * tool slug + input schema at runtime (how generated apps will wire actions). + */ +export async function searchToolsInToolkit( + toolkitSlug: string, + query: string, +): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> { + try { + const { items } = await composioClient.searchTools(query, [toolkitSlug]); + return { + tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { tools: [], error: message }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a1f6c4a6..754ad77b 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1304,6 +1304,12 @@ export function setupIpcHandlers() { 'composio:list-toolkits': async () => { return composioHandler.listToolkits(); }, + 'composio:execute-tool': async (_event, args) => { + return composioHandler.executeTool(args.toolkitSlug, args.toolSlug, args.arguments); + }, + 'composio:search-tools': async (_event, args) => { + return composioHandler.searchToolsInToolkit(args.toolkitSlug, args.query); + }, 'migration:check-composio-google': async () => { return qualifyAndDisconnectComposioGoogle(); }, diff --git a/apps/x/apps/renderer/src/components/mini-app-frame.tsx b/apps/x/apps/renderer/src/components/mini-app-frame.tsx index 0e988ada..77ccd40d 100644 --- a/apps/x/apps/renderer/src/components/mini-app-frame.tsx +++ b/apps/x/apps/renderer/src/components/mini-app-frame.tsx @@ -5,37 +5,62 @@ import { MINI_APP_MESSAGE } from '@/mini-apps/types' // Host side of the Mini App bridge. Renders the app's self-contained HTML in a // sandboxed iframe and answers the postMessage protocol in mini-apps/types.ts. // -// Phase 1: data is delivered from the static app.data, per-app state lives in -// memory only, and callAction is stubbed (returns a friendly demo result). Later -// phases replace the action/state handlers with real IPC to the agent + Composio. +// Phase 2: the bridge is real. App rpc calls (callAction/searchTools/ +// isConnected/connect) are scope-checked against the app's declared scope and +// routed to Composio over IPC. Per-app state is still in-memory for now. // Sandbox intentionally omits allow-same-origin: the app gets an opaque origin so // it cannot reach host cookies/storage. The bridge is the only channel out. const SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads' -// Phase 1 stub: pretend the Composio action succeeded. -function stubActionResult(action: string): { message: string } { - switch (action) { - case 'repost': return { message: 'Reposted (demo)' } - case 'reply': return { message: 'Reply sent (demo)' } - case 'mark_read': return { message: 'Dismissed' } - default: return { message: action + ' done (demo)' } - } -} - export function MiniAppFrame({ app }: { app: MiniApp }) { const iframeRef = useRef(null) - // In-memory per-app state for Phase 1 (resets when the frame unmounts). + // In-memory per-app state for Phase 1/2 (resets when the frame unmounts). const stateRef = useRef>({}) useEffect(() => { - // Reset state when switching apps. stateRef.current = {} function postToFrame(message: unknown) { iframeRef.current?.contentWindow?.postMessage(message, '*') } + // Dispatch an app rpc call to real Composio IPC. Throws on scope violation + // or failure; the caller turns that into an rpc-result error. + async function handleRpc(method: string, params: Record): Promise { + const scope = typeof params.scope === 'string' ? params.scope : '' + if (!scope || !app.scope.includes(scope)) { + throw new Error(`This app is not allowed to use "${scope || '(none)'}".`) + } + switch (method) { + case 'isConnected': { + const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: scope }) + return r.isConnected + } + case 'connect': { + const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: scope }) + if (!r.success && r.error) throw new Error(r.error) + return { started: r.success } + } + case 'searchTools': { + const query = typeof params.query === 'string' ? params.query : '' + const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: scope, query }) + if (r.error) throw new Error(r.error) + return r.tools + } + case 'callAction': { + const tool = typeof params.tool === 'string' ? params.tool : '' + if (!tool) throw new Error('No tool specified.') + const args = (params.args && typeof params.args === 'object' ? params.args : {}) as Record + const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: scope, toolSlug: tool, arguments: args }) + if (!r.successful) throw new Error(r.error || 'Action failed.') + return r.data + } + default: + throw new Error(`Unknown bridge method "${method}".`) + } + } + function handleMessage(event: MessageEvent) { const frameWindow = iframeRef.current?.contentWindow if (!frameWindow || event.source !== frameWindow) return @@ -49,27 +74,16 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current }) break } - case MINI_APP_MESSAGE.action: { - // Phase 1: stubbed. (Phase 2 enforces app.scope and calls Composio.) - const allowed = app.scope.includes(msg.scope) - if (!allowed) { - postToFrame({ - type: MINI_APP_MESSAGE.actionResult, - id: msg.id, + case MINI_APP_MESSAGE.rpc: { + const { id, method, params } = msg + handleRpc(method, (params ?? {}) as Record) + .then((result) => postToFrame({ type: MINI_APP_MESSAGE.rpcResult, id, ok: true, result })) + .catch((err) => postToFrame({ + type: MINI_APP_MESSAGE.rpcResult, + id, ok: false, - error: 'Action scope "' + msg.scope + '" not granted to this app', - }) - break - } - // Small delay so the UI's busy state is visible. - window.setTimeout(() => { - postToFrame({ - type: MINI_APP_MESSAGE.actionResult, - id: msg.id, - ok: true, - result: stubActionResult(msg.action), - }) - }, 350) + error: err instanceof Error ? err.message : String(err), + })) break } case MINI_APP_MESSAGE.setState: { diff --git a/apps/x/apps/renderer/src/mini-apps/apps/github-radar.ts b/apps/x/apps/renderer/src/mini-apps/apps/github-radar.ts new file mode 100644 index 00000000..0a2b873b --- /dev/null +++ b/apps/x/apps/renderer/src/mini-apps/apps/github-radar.ts @@ -0,0 +1,314 @@ +// Sample Mini App: GitHub Dashboard. +// +// A custom dashboard: pick repos to track, then view their open pull requests +// (title, author, description) — fetched LIVE from GitHub through the Phase 2 +// bridge (searchTools -> callAction against a Composio managed-OAuth2 toolkit). +// Tracked repos persist via the per-app state store. + +import { buildMiniAppHtml } from '../runtime' +import type { MiniApp } from '../types' + +// Seed repos shown on first open (the user can add/remove their own). +const data = { + seedRepos: [ + { owner: 'vercel', repo: 'next.js' }, + { owner: 'rowboatlabs', repo: 'rowboat' }, + ], +} + +const style = ` +.app { height:100%; overflow:auto; background:#0d1117; color:#e6edf3; } +.wrap { max-width:760px; margin:0 auto; padding:24px 16px 64px; } +.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f0f6fc; } +.sub { font-size:13px; color:#8b949e; margin:0 0 18px; } +.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 16px; padding:12px 14px; border:1px solid #1f6feb; background:rgba(31,111,235,0.12); border-radius:10px; } +.banner.ok { border-color:#238636; background:rgba(35,134,54,0.12); } +.banner-text { font-size:13px; color:#e6edf3; } +.banner-btn { background:#238636; color:#fff; border:0; border-radius:8px; padding:7px 14px; font-size:13px; font-weight:600; cursor:pointer; } +.banner-btn:disabled { opacity:.6; cursor:default; } +.profile { display:flex; gap:14px; align-items:center; margin-bottom:16px; } +.avatar { width:64px; height:64px; border-radius:50%; border:1px solid #30363d; flex:0 0 auto; object-fit:cover; } +.avatar-fb { width:64px; height:64px; border-radius:50%; background:#21262d; display:none; align-items:center; justify-content:center; font-size:24px; font-weight:600; color:#8b949e; flex:0 0 auto; } +.pname { font-size:17px; font-weight:600; color:#f0f6fc; } +.plogin { font-size:13px; color:#8b949e; } +.pbio { font-size:13px; color:#adbac7; margin-top:4px; } +.pstats { display:flex; gap:16px; margin-top:8px; font-size:12px; color:#8b949e; } +.pstats b { color:#e6edf3; } +.contrib { border:1px solid #30363d; background:#161b22; border-radius:10px; padding:14px; margin-bottom:18px; } +.contrib-title { font-size:13px; font-weight:600; color:#f0f6fc; margin-bottom:10px; } +.contrib-img { width:100%; height:auto; display:block; border-radius:6px; } +.contrib-note { font-size:12px; color:#6e7681; } +.adder { display:flex; gap:8px; margin-bottom:18px; } +.adder input { flex:1; background:#0d1117; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:8px 11px; font-size:13px; outline:none; } +.adder input:focus { border-color:#1f6feb; } +.adder button { background:#21262d; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:0 14px; font-size:13px; font-weight:600; cursor:pointer; } +.adder button:hover { background:#30363d; } +.repo { border:1px solid #30363d; background:#161b22; border-radius:10px; margin-bottom:14px; overflow:hidden; } +.repo-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 14px; border-bottom:1px solid #21262d; } +.repo-name { font-size:14px; font-weight:600; color:#2f81f7; } +.repo-actions { display:flex; gap:8px; align-items:center; } +.repo-count { font-size:12px; color:#8b949e; } +.icon-btn { background:none; border:0; color:#8b949e; cursor:pointer; font-size:13px; padding:2px 6px; border-radius:6px; } +.icon-btn:hover { background:#21262d; color:#e6edf3; } +.pr { border-bottom:1px solid #21262d; } +.pr:last-child { border-bottom:0; } +.pr-row { display:flex; gap:10px; align-items:flex-start; padding:10px 14px; cursor:pointer; } +.pr-row:hover { background:#1c2128; } +.pr-icon { color:#3fb950; font-size:13px; line-height:1.5; flex:0 0 auto; } +.pr-main { min-width:0; flex:1; } +.pr-title { font-size:13px; color:#e6edf3; } +.pr-sub { font-size:12px; color:#8b949e; margin-top:2px; } +.pr-body { padding:0 14px 12px 38px; font-size:13px; line-height:1.5; color:#adbac7; white-space:pre-wrap; word-wrap:break-word; } +.pr-body.empty { color:#6e7681; font-style:italic; } +.state { padding:14px; font-size:13px; color:#8b949e; } +.state.err { color:#f85149; } +.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#238636; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:8px; opacity:0; transition:opacity .2s; pointer-events:none; } +.toast.show { opacity:1; } +.toast.err { background:#da3633; } +.empty-state { padding:32px 14px; text-align:center; color:#6e7681; font-size:13px; } +.loading { padding:48px 16px; text-align:center; color:#8b949e; } +` + +const script = ` +var root = document.getElementById('root'); +var seed = []; +var repos = null; // [{owner,repo}], persisted +var expanded = {}; // pr key -> bool +var prCache = {}; // 'owner/repo' -> { loading, error, prs } +var profile = null; // authenticated user profile +var profileState = null; // { loading, error } +var repoInput = ''; +var connected = null; +var connecting = false; +var toastTimer = null; + +function esc(s){ return String(s).replace(/&/g,'&').replace(//g,'>'); } +function key(o,r){ return o + '/' + r; } +function persist(){ window.rowboat.setState({ repos: repos }); } + +// ---- live GitHub reads via the bridge ------------------------------------- +function asArray(d){ + if (Array.isArray(d)) return d; + if (d && typeof d === 'object') { + var keys = ['details','data','items','pull_requests','pullRequests','response_data','result']; + for (var i=0;i=0) return false; return true; } + for (var i=0;i=0 && t.indexOf('LIST')>=0 && ok(t)) return tools[m]; } + return null; +} +function fetchPRs(o, r){ + var k = key(o,r); + prCache[k] = { loading:true }; render(); + var args = { owner:o, repo:r, state:'open', sort:'updated', direction:'desc', per_page:20 }; + // Canonical slug first; fall back to a strict search only if it's unknown. + window.rowboat.callAction('github', 'GITHUB_LIST_PULL_REQUESTS', args).catch(function(){ + return window.rowboat.searchTools('github','list pull requests for a repository').then(function(tools){ + var tool = pickPRTool(tools || []); + if (!tool) throw new Error('could not find a list-pull-requests tool'); + return window.rowboat.callAction('github', tool.slug, args); + }); + }).then(function(d){ + prCache[k] = { loading:false, prs: normPRs(d) }; render(); + }).catch(function(e){ + prCache[k] = { loading:false, error: (e && e.message ? e.message : String(e)) }; render(); + }); +} +function fetchAll(){ if (!connected) return; loadProfile(); if (!repos) return; repos.forEach(function(rp){ var k=key(rp.owner,rp.repo); if (!prCache[k]) fetchPRs(rp.owner, rp.repo); }); } + +// ---- authenticated user profile ------------------------------------------- +function asObj(d){ + if (d && typeof d === 'object'){ + if (d.login) return d; + var ks = ['data','details','response_data','result','user']; + for (var i=0;i