From c424976926a182d30285f9430b481f874eeefeac Mon Sep 17 00:00:00 2001 From: Gagan Date: Thu, 2 Jul 2026 01:06:05 +0530 Subject: [PATCH] fix(mini-apps): reliability fixes + self-contained data loading From two rounds of copilot build feedback: - runtime: null-check hallucinated tool names (was crashing the whole run) - add window.rowboat.fetch + builtin fetch-url: CORS-safe HTTP for UI and bg-task agents (no shell needed for plain HTTP) - mini-app-set-data/install: reject stringified JSON; require object - manifest dataContract (requiredKeys/nonEmptyArrayKeys) enforced on writes so a stray/legacy task can't corrupt an app's data or wipe series - self-contained data: data.json is a served sibling (dist/), apps fetch it via relative URL; removed host-side data injection - build-mini-app skill: CORS/fetch-url/no-shell/capable-model/dataContract gotchas --- apps/x/apps/main/src/ipc.ts | 3 + apps/x/apps/main/src/mini-apps-handler.ts | 54 ++++++++-- .../src/components/mini-app-frame.tsx | 23 +++-- .../src/components/mini-apps-view.tsx | 99 +++++++++++-------- apps/x/apps/renderer/src/mini-apps/runtime.ts | 24 +++-- apps/x/apps/renderer/src/mini-apps/types.ts | 2 +- apps/x/packages/core/src/agents/runtime.ts | 8 ++ .../assistant/skills/build-mini-app/skill.ts | 47 +++++++-- .../core/src/application/lib/builtin-tools.ts | 75 +++++++++++++- apps/x/packages/shared/src/ipc.ts | 17 ++++ apps/x/packages/shared/src/mini-app.ts | 11 +++ 11 files changed, 288 insertions(+), 75 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 04163f38..7cc36036 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1324,6 +1324,9 @@ export function setupIpcHandlers() { 'mini-apps:get-data': async (_event, args) => { return miniAppsHandler.getAppData(args.id); }, + 'mini-apps:fetch': async (_event, args) => { + return miniAppsHandler.proxyFetch(args); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); diff --git a/apps/x/apps/main/src/mini-apps-handler.ts b/apps/x/apps/main/src/mini-apps-handler.ts index ecb47a82..715fe9ae 100644 --- a/apps/x/apps/main/src/mini-apps-handler.ts +++ b/apps/x/apps/main/src/mini-apps-handler.ts @@ -24,7 +24,7 @@ function appDir(id: string): string { */ export const MINIAPP_BRIDGE_JS = ` (function () { - var data = null, state = null; + var data = null, dataLoaded = false, state = null; var dataCbs = [], stateCbs = []; var pending = {}, seq = 0; function post(msg) { parent.postMessage(msg, '*'); } @@ -35,13 +35,18 @@ export const MINIAPP_BRIDGE_JS = ` post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params }); }); } + // Apps are self-contained: data.json is a served sibling of index.html, loaded + // via a relative fetch (same origin, no CORS). Rowboat does not inject it. + function loadData() { + return fetch('data.json', { cache: 'no-store' }) + .then(function (r) { return r && r.ok ? r.json() : null; }) + .then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; }) + .catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; }); + } window.addEventListener('message', function (e) { var m = e.data; if (!m || typeof m !== 'object') return; - if (m.type === 'rowboat:mini-app:data') { - data = m.data; - dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); - } else if (m.type === 'rowboat:mini-app:state') { + if (m.type === 'rowboat:mini-app:state') { state = m.state; stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); } else if (m.type === 'rowboat:mini-app:rpc-result') { @@ -51,7 +56,8 @@ export const MINIAPP_BRIDGE_JS = ` }); window.rowboat = { getData: function () { return data; }, - onData: function (cb) { dataCbs.push(cb); if (data !== null) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; }, + onData: function (cb) { dataCbs.push(cb); if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; }, + refreshData: function () { return loadData(); }, getState: function () { return state; }, onState: function (cb) { stateCbs.push(cb); if (state !== null) { try { cb(state); } catch (_) {} } return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); }; }, setState: function (patch) { state = Object.assign({}, state || {}, patch); post({ type: 'rowboat:mini-app:setState', patch: patch }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); }, @@ -59,8 +65,15 @@ export const MINIAPP_BRIDGE_JS = ` searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); }, isConnected: function (scope) { return rpc('isConnected', { scope: scope }); }, connect: function (scope) { return rpc('connect', { scope: scope }); }, + // CORS-safe HTTP via the main process. Resolves to { ok, status, text, json } + // (json is the parsed body when it's valid JSON, else null). + fetch: function (url, opts) { + return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) }) + .then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; }); + }, ready: function () { post({ type: 'rowboat:mini-app:ready' }); }, }; + loadData(); })(); `; @@ -108,6 +121,35 @@ export function listApps(): { manifests: Manifest[] } { return { manifests }; } +/** + * Proxy an HTTP request through the main process so Mini Apps can call + * third-party APIs that don't send CORS headers (the sandboxed app:// origin + * can't fetch them directly). GET/POST over http(s) only. + */ +export async function proxyFetch(input: { + url: string; + method?: string; + headers?: Record; + body?: string; +}): Promise<{ ok: boolean; status: number; statusText: string; text: string; error?: string }> { + try { + const parsed = new URL(input.url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, status: 0, statusText: '', text: '', error: 'Only http(s) URLs are allowed.' }; + } + const method = (input.method || 'GET').toUpperCase(); + const res = await fetch(input.url, { + method, + headers: input.headers, + body: method === 'GET' || method === 'HEAD' ? undefined : input.body, + }); + const text = await res.text(); + return { ok: res.ok, status: res.status, statusText: res.statusText, text }; + } catch (e) { + return { ok: false, status: 0, statusText: '', text: '', error: e instanceof Error ? e.message : String(e) }; + } +} + /** Read an app's latest data.json (agent output), or null if absent/invalid. */ export function getAppData(id: string): { data: unknown | null } { try { 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 734bd73d..975f1e0a 100644 --- a/apps/x/apps/renderer/src/components/mini-app-frame.tsx +++ b/apps/x/apps/renderer/src/components/mini-app-frame.tsx @@ -29,6 +29,17 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest } } async function handleRpc(method: string, params: Record): Promise { + // fetch is a network proxy, not toolkit-scoped — handle before the scope gate. + if (method === 'fetch') { + const url = typeof params.url === 'string' ? params.url : '' + if (!url) throw new Error('No url specified.') + return window.ipc.invoke('mini-apps:fetch', { + url, + method: typeof params.method === 'string' ? params.method : undefined, + headers: (params.headers && typeof params.headers === 'object' ? params.headers : undefined) as Record | undefined, + body: typeof params.body === 'string' ? params.body : undefined, + }) + } const s = typeof params.scope === 'string' ? params.scope : '' if (!s || !scope.includes(s)) { throw new Error(`This app is not allowed to use "${s || '(none)'}".`) @@ -62,15 +73,9 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest } } } - async function handleReady() { - let data: unknown = null - try { - const r = await window.ipc.invoke('mini-apps:get-data', { id: manifest.id }) - data = r.data - } catch { - data = null - } - postToFrame({ type: MINI_APP_MESSAGE.data, data }) + // Apps load their own data.json (served sibling) via a relative fetch; the + // host only provides per-app UI state on ready. + function handleReady() { postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current }) } diff --git a/apps/x/apps/renderer/src/components/mini-apps-view.tsx b/apps/x/apps/renderer/src/components/mini-apps-view.tsx index b5b35726..2f8cc9a8 100644 --- a/apps/x/apps/renderer/src/components/mini-apps-view.tsx +++ b/apps/x/apps/renderer/src/components/mini-apps-view.tsx @@ -35,53 +35,65 @@ const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERN // Tailwind tokens). Injected once; per-card accent is passed via CSS variables. const CARD_CSS = ` /* Light is the baseline; .dark (set on ) overrides the surface tokens. - The accent system (badge/pill/glow/pattern via --accent) is identical in both. */ + The accent system (badge/pill/glow/pattern via --accent) is identical in both. + Surfaces are brushed-metal: a base gradient + a diagonal sheen + a hairline + top highlight. Sizing responds to the PANE width via container queries. */ .ma-page { - --ma-bg:#f6f6f7; - --ma-card-from:#ffffff; --ma-card-to:#fbfbfc; - --ma-card-hover-from:#ffffff; --ma-card-hover-to:#f5f5f7; - --ma-border:rgba(0,0,0,0.08); --ma-border-hover:rgba(0,0,0,0.14); - --ma-shadow:0 1px 2px rgba(0,0,0,0.06); - --ma-title:#101013; --ma-desc:rgba(0,0,0,0.62); - --ma-h1:#101013; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42); - --ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5); --ma-off-dot:rgba(0,0,0,0.4); + container-type: inline-size; + --ma-bg:#eceef1; + /* metallic silver */ + --ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee; + --ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1; + --ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9); + --ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15); + --ma-shadow:0 1px 2px rgba(0,0,0,0.08); + --ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6); + --ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42); + --ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5); --ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4); - --ma-pat-opacity:0.16; --ma-glow-opacity:0.22; --ma-glow-hover-opacity:0.30; - --ma-badge-mix:22%; --ma-pill-mix:18%; + --ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24; + --ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%; height:100%; overflow:auto; background:var(--ma-bg); } .dark .ma-page { - --ma-bg:#0c0c0e; - --ma-card-from:#17171B; --ma-card-to:#111114; - --ma-card-hover-from:#19191e; --ma-card-hover-to:#121215; - --ma-border:rgba(255,255,255,0.06); --ma-border-hover:rgba(255,255,255,0.09); - --ma-shadow:0 1px 2px rgba(0,0,0,0.18); - --ma-title:#f5f5f5; --ma-desc:rgba(255,255,255,0.68); - --ma-h1:#f5f5f5; --ma-sub:rgba(255,255,255,0.55); --ma-lastrun:rgba(255,255,255,0.4); - --ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5); --ma-off-dot:rgba(255,255,255,0.45); + --ma-bg:#0b0b0d; + /* metallic gunmetal */ + --ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116; + --ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419; + --ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09); + --ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12); + --ma-shadow:0 1px 2px rgba(0,0,0,0.35); + --ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66); + --ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38); + --ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5); --ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38); - --ma-pat-opacity:0.07; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.13; - --ma-badge-mix:15%; --ma-pill-mix:13%; + --ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16; + --ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%; } -.ma-inner { max-width:1080px; margin:0 auto; padding:32px 28px 48px; } -.ma-h1 { font-size:24px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; } -.ma-sub { font-size:14px; color:var(--ma-sub); margin:0 0 28px; } -.ma-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:28px; } -@media (max-width:1040px) { .ma-grid { grid-template-columns:repeat(2, 1fr); } } -@media (max-width:700px) { .ma-grid { grid-template-columns:1fr; } } +.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; } +.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; } +.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(18px,2.5cqw,28px); } +/* Fluid columns: as many ~250px cards as fit the pane; single column when narrow. */ +.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); } .ma-card { - position:relative; min-height:252px; border-radius:20px; + position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px solid var(--ma-border); - background:linear-gradient(160deg, var(--ma-card-from) 0%, var(--ma-card-to) 100%); - padding:22px; text-align:left; cursor:pointer; overflow:hidden; + background: + linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%), + linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%), + linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%); + padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden; display:flex; flex-direction:column; isolation:isolate; - box-shadow: var(--ma-shadow), 0 6px 18px -20px var(--glow); - transition: box-shadow .22s ease, border-color .22s ease, background .22s ease; + box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow); + transition: box-shadow .22s ease, border-color .22s ease, background .22s ease, transform .22s ease; } .ma-card:hover { border-color: var(--ma-border-hover); - background:linear-gradient(160deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-to) 100%); + background: + linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%), + linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%), + linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%); } /* decorative pattern layer (accent-tinted, very low opacity) */ .ma-card::before { @@ -110,22 +122,27 @@ const CARD_CSS = ` } .ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); } -.ma-title { font-size:22px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:18px 0 8px; } +.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; } .ma-desc { - font-size:15px; font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0; + font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } -.ma-footer { margin-top:auto; padding-top:22px; display:flex; align-items:center; justify-content:space-between; gap:12px; } -.ma-source { font-size:12px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 11px; border-radius:999px; } -.ma-lastrun { font-size:12px; color:var(--ma-lastrun); } +.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; } +.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; } +.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .ma-new { - min-height:252px; border-radius:20px; border:1px dashed var(--ma-new-border); + min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border); background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; color:var(--ma-new-title); cursor:default; transition: border-color .2s ease, color .2s ease; } -.ma-new-title { font-size:15px; font-weight:600; color:var(--ma-new-title); } -.ma-new-hint { font-size:12px; color:var(--ma-new-hint); } +.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); } +.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; } + +/* Very narrow pane: tighten footer so source + last-run don't collide. */ +@container (max-width: 380px) { + .ma-footer { flex-direction:column; align-items:flex-start; gap:6px; } +} ` function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) { diff --git a/apps/x/apps/renderer/src/mini-apps/runtime.ts b/apps/x/apps/renderer/src/mini-apps/runtime.ts index cc64cf86..1dbdba06 100644 --- a/apps/x/apps/renderer/src/mini-apps/runtime.ts +++ b/apps/x/apps/renderer/src/mini-apps/runtime.ts @@ -15,7 +15,7 @@ */ const BRIDGE_SHIM = /* js */ ` (function () { - var data = null, state = null; + var data = null, dataLoaded = false, state = null; var dataCbs = [], stateCbs = []; var pending = {}, seq = 0; function post(msg) { parent.postMessage(msg, '*'); } @@ -26,13 +26,17 @@ const BRIDGE_SHIM = /* js */ ` post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params }); }); } + // data.json is a served sibling of index.html — apps load it themselves. + function loadData() { + return fetch('data.json', { cache: 'no-store' }) + .then(function (r) { return r && r.ok ? r.json() : null; }) + .then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; }) + .catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; }); + } window.addEventListener('message', function (e) { var m = e.data; if (!m || typeof m !== 'object') return; - if (m.type === 'rowboat:mini-app:data') { - data = m.data; - dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); - } else if (m.type === 'rowboat:mini-app:state') { + if (m.type === 'rowboat:mini-app:state') { state = m.state; stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); } else if (m.type === 'rowboat:mini-app:rpc-result') { @@ -45,9 +49,10 @@ const BRIDGE_SHIM = /* js */ ` }); window.rowboat = { getData: function () { return data; }, + refreshData: function () { return loadData(); }, onData: function (cb) { dataCbs.push(cb); - if (data !== null) { try { cb(data); } catch (_) {} } + if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; }, getState: function () { return state; }, @@ -70,8 +75,15 @@ const BRIDGE_SHIM = /* js */ ` isConnected: function (scope) { return rpc('isConnected', { scope: scope }); }, // Trigger the Composio OAuth flow for the toolkit. Resolves when started. connect: function (scope) { return rpc('connect', { scope: scope }); }, + // CORS-safe HTTP via the main process (for third-party APIs without CORS). + // Resolves to { ok, status, text, json } (json = parsed body or null). + fetch: function (url, opts) { + return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) }) + .then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; }); + }, ready: function () { post({ type: 'rowboat:mini-app:ready' }); }, }; + loadData(); })(); ` diff --git a/apps/x/apps/renderer/src/mini-apps/types.ts b/apps/x/apps/renderer/src/mini-apps/types.ts index 5ad505bc..f02aecaf 100644 --- a/apps/x/apps/renderer/src/mini-apps/types.ts +++ b/apps/x/apps/renderer/src/mini-apps/types.ts @@ -54,7 +54,7 @@ export type MiniApp = { * - isConnected: is the toolkit connected? params: { scope } * - connect: trigger the Composio OAuth flow params: { scope } */ -export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' +export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' | 'fetch' /** Messages sent from the iframe (app) up to the host (renderer). */ export type MiniAppOutboundMessage = diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index da756b2c..8e4f4a0e 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -1541,6 +1541,14 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated for (const part of message.content) { if (part.type === "tool-call") { const underlyingTool = agent.tools![part.toolName]; + // The model can hallucinate a tool name that isn't declared. + // Skip it here instead of dereferencing undefined (which would + // crash the whole run); the SDK returns an error tool-result + // for the unknown call so the model can self-correct. + if (!underlyingTool) { + loopLogger.log('model called unknown tool, skipping:', part.toolName); + continue; + } if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") { loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId); const rawOptions = (part.arguments as { options?: unknown }).options; diff --git a/apps/x/packages/core/src/application/assistant/skills/build-mini-app/skill.ts b/apps/x/packages/core/src/application/assistant/skills/build-mini-app/skill.ts index 25347249..0f5fbd59 100644 --- a/apps/x/packages/core/src/application/assistant/skills/build-mini-app/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/build-mini-app/skill.ts @@ -63,13 +63,19 @@ bridge: include the shim and code against \`window.rowboat\`: \`\`\` \`window.rowboat\` API: -- \`getData()\` / \`onData(cb)\` — the app's data (host serves \`data.json\`). Register - \`onData\` then call \`ready()\`. +- \`getData()\` / \`onData(cb)\` / \`refreshData()\` — the app's data. \`data.json\` is a + **served sibling of index.html**, so the app is self-contained: \`onData\` + fetches \`data.json\` via a relative URL (you can also just \`fetch('data.json')\` + yourself). \`refreshData()\` re-fetches. Rowboat does NOT inject data. - \`getState()\` / \`setState(patch)\` — per-app persistent UI state. - \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth. - \`searchTools(scope, query)\` → [{slug,name,description}], and \`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is enforced against the manifest, so only declared toolkits work. +- \`fetch(url, opts?)\` → { ok, status, text, json } — a CORS-safe HTTP proxy + through the main process. **Use this for any third-party API, never the + browser's \`fetch\`** (public APIs rarely send CORS headers, so direct fetch + from the app origin fails with "Failed to fetch"). - \`ready()\` — call once after registering callbacks to receive initial data/state. Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin @@ -84,14 +90,25 @@ allows them but offline-safe is better). Render loading / empty / error states. ## 4. Data pipeline (agent-backed apps only) Create a background task with \`create-background-task\` whose instructions: -- fetch the data via Composio (or browse via the embedded browser for social - feeds), and -- call **\`mini-app-set-data\`** with \`{ appId: "", data: }\`. +- fetch the data — via **Composio** if a toolkit exists, otherwise via the + builtin **\`fetch-url\`** tool (server-side HTTP, no CORS). **The bg-task agent + has NO shell** (\`executeCommand\` is disabled headlessly) and \`rowboat.fetch\` + is frontend-only — never tell it to run a \`refresh.sh\`/script; it fetches via + \`fetch-url\` or Composio, and +- call **\`mini-app-set-data\`** with \`{ appId: "", data: }\` — + pass the **object directly, never \`JSON.stringify\`** it. If a fetch fails, + **keep the last good data** (don't overwrite good series with empty ones). + +Set the manifest's **\`dataContract\`** (\`requiredKeys\` + \`nonEmptyArrayKeys\`) +to the shape the UI needs — \`mini-app-set-data\` enforces it, so a stale or +buggy run can't corrupt the app with the wrong shape or wipe series with empties. The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\` atomically (deterministic path + write — the agent never touches files). Set the -manifest's \`agent\` field to the task's slug. Give the task a sensible trigger -(cron/window) from the background-task skill. +manifest's \`agent\` field to the task's slug, and give the task a **capable model** +(e.g. a Claude Sonnet / GPT-class model) via its model override — the default +light model fabricates output and hallucinates tool names on side-effect tasks. +Give it a sensible trigger (cron/window) from the background-task skill. ## 5. Finalize @@ -104,6 +121,22 @@ Then **open it for the user**: call \`app-navigation\` with (under Mini Apps / its title) and shows an "Opened " card in the chat. Only do this once the app is installed AND its data is populated, so it renders ready. +## Common gotchas (read before building) + +- **CORS:** third-party APIs usually block browser fetches. From the app UI use + \`rowboat.fetch(url)\`, not \`fetch(url)\`. If you don't, it fails with + "Failed to fetch" even though curl works. +- **No Composio toolkit for the data?** That's fine — use \`rowboat.fetch\` from a + live app, or a bg-task that fetches with **\`fetch-url\`** and calls + \`mini-app-set-data\`. Don't force a toolkit that doesn't exist (e.g. there's no + FX/currency toolkit), and don't reach for a shell or MCP server for plain HTTP. +- **data.json shape:** pass a plain **object** to \`mini-app-set-data\`; never + \`JSON.stringify\` it first (double-encoding breaks the UI silently). +- **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\` + steps for the data agent — it can't run them headlessly. +- **model:** set a capable model on any data/side-effect bg-task; the default is + too weak and will fabricate results. + ## Manifest schema (manifest.json) \`\`\`yaml diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 69f2c157..d91fa176 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1528,8 +1528,13 @@ export const BuiltinTools: z.infer = { await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2)); await fs.writeFile(path.join(dist, 'index.html'), html); if (data !== undefined) { - const dataPath = path.join(dir, 'data.json'); - try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(data, null, 2)); } + let payload: unknown = data; + if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } } + if (payload !== undefined && payload !== null && typeof payload === 'object') { + // sibling of index.html so the app can fetch('data.json') + const dataPath = path.join(dist, 'data.json'); + try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(payload, null, 2)); } + } } return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` }; } catch (e) { @@ -1545,11 +1550,45 @@ export const BuiltinTools: z.infer = { }), execute: async ({ appId, data }: { appId: string; data: unknown }) => { try { + // Guard the #1 mistake: passing a JSON *string* (JSON.stringify'd) + // instead of the object. Auto-parse a string; reject non-objects so + // the UI never gets a double-encoded blob it can't read. + let payload: unknown = data; + if (typeof payload === 'string') { + try { payload = JSON.parse(payload); } + catch { return { success: false, error: 'data must be a JSON object/array — pass the object directly, do NOT JSON.stringify it.' }; } + } + if (payload === null || typeof payload !== 'object') { + return { success: false, error: 'data must be a JSON object or array.' }; + } const dir = path.join(WorkDir, 'apps', appId); - await fs.mkdir(dir, { recursive: true }); - const dataPath = path.join(dir, 'data.json'); + // The app must already exist — never create a stray folder, and load + // its dataContract to guard the write. + let manifest: z.infer; + try { + manifest = MiniAppManifest.parse(JSON.parse(await fs.readFile(path.join(dir, 'manifest.json'), 'utf-8'))); + } catch { + return { success: false, error: `No installed Mini App "${appId}". Install it first (mini-app-install).` }; + } + const contract = manifest.dataContract; + if (contract && !Array.isArray(payload)) { + const obj = payload as Record; + const missing = (contract.requiredKeys ?? []).filter((k) => obj[k] === undefined || obj[k] === null); + if (missing.length) { + return { success: false, error: `data is missing required key(s): ${missing.join(', ')}. Match the app's data shape — do NOT write a different shape; keep the last good data.` }; + } + const badArrays = (contract.nonEmptyArrayKeys ?? []).filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0); + if (badArrays.length) { + return { success: false, error: `these key(s) must be non-empty arrays: ${badArrays.join(', ')}. Don't overwrite good series with empty ones — keep the last good data.` }; + } + } + // data.json is a served sibling of index.html so apps fetch it + // via a relative URL (app://miniapp//data.json). + const distDir = path.join(dir, 'dist'); + await fs.mkdir(distDir, { recursive: true }); + const dataPath = path.join(distDir, 'data.json'); const tmp = `${dataPath}.tmp`; - await fs.writeFile(tmp, JSON.stringify(data, null, 2)); + await fs.writeFile(tmp, JSON.stringify(payload, null, 2)); await fs.rename(tmp, dataPath); return { success: true, appId }; } catch (e) { @@ -1557,6 +1596,32 @@ export const BuiltinTools: z.infer = { } }, }, + 'fetch-url': { + description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.", + inputSchema: z.object({ + url: z.string().describe('The http(s) URL to fetch.'), + method: z.enum(['GET', 'POST']).optional().describe('HTTP method (default GET).'), + headers: z.record(z.string(), z.string()).optional().describe('Optional request headers.'), + body: z.string().optional().describe('Request body (for POST).'), + }), + execute: async ({ url, method, headers, body }: { url: string; method?: string; headers?: Record; body?: string }) => { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, status: 0, error: 'Only http(s) URLs are allowed.' }; + } + const m = (method || 'GET').toUpperCase(); + const res = await fetch(url, { method: m, headers, body: m === 'GET' || m === 'HEAD' ? undefined : body }); + let text = await res.text(); + const MAX = 200_000; + const truncated = text.length > MAX; + if (truncated) text = text.slice(0, MAX); + return { ok: res.ok, status: res.status, statusText: res.statusText, body: text, truncated }; + } catch (e) { + return { ok: false, status: 0, error: e instanceof Error ? e.message : String(e) }; + } + }, + }, 'run-live-note-agent': { description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.", inputSchema: z.object({ diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 85dfab94..ccca5bf9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1033,6 +1033,23 @@ const ipcSchemas = { req: z.object({ id: z.string() }), res: z.object({ data: z.unknown().nullable() }), }, + // Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the + // sandboxed app origin). GET/POST to http(s) only. + 'mini-apps:fetch': { + req: z.object({ + url: z.string(), + method: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), + }), + res: z.object({ + ok: z.boolean(), + status: z.number(), + statusText: z.string(), + text: z.string(), + error: z.string().optional(), + }), + }, 'composio:didConnect': { req: z.object({ toolkitSlug: z.string(), diff --git a/apps/x/packages/shared/src/mini-app.ts b/apps/x/packages/shared/src/mini-app.ts index 752e37cb..6198acc2 100644 --- a/apps/x/packages/shared/src/mini-app.ts +++ b/apps/x/packages/shared/src/mini-app.ts @@ -26,6 +26,17 @@ export const MiniAppManifest = z.object({ entry: z.string().default('dist/index.html'), /** Optional associated background-task slug that produces data.json. */ agent: z.string().optional(), + /** + * Optional guard on what may be written to data.json (via mini-app-set-data). + * Prevents a stray/legacy task from corrupting the app with the wrong shape or + * wiping good series with empty ones. + */ + dataContract: z.object({ + /** Top-level keys that must be present and non-null. */ + requiredKeys: z.array(z.string()).default([]), + /** Top-level keys that must be non-empty arrays. */ + nonEmptyArrayKeys: z.array(z.string()).default([]), + }).optional(), }); export type MiniAppManifest = z.infer;