From 06460020d08a7c1a00ccd8748760cff8f1071214 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 11:47:27 -0700 Subject: [PATCH] Send the panel's app requests to the app's server without Claude refuses in panels, so relative addresses reached the host's site: styles, Cesium assets and API calls all failed. The panel now points fetch, XHR, beacons, images, media, links, workers and added style url()s at the app's server, and declares its policy under OpenAI's widget key as well. --- src/tools/globePanel.js | 141 +++++++++++++++++++++++++++++++--- src/tools/globePanel.test.mjs | 8 +- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 58d8007dd..c872e4698 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -96,31 +96,142 @@ function panelHtml(appOrigin) { const postView = (view) => window.postMessage({ type: 'gev:view', id: nextId++, view }, '*'); + // The panel page lives on the host's site, which serves none of the app. + // Hosts may refuse , so the app's own addresses (relative ones, and + // absolute ones on this page's site) are sent to the app's server instead. + // The page's own site; location.origin is "null" in a srcdoc document. + const PAGE_ORIGIN = new URL(document.baseURI).origin; + const toApp = (value) => { + let url; + try { + url = new URL(String(value), document.baseURI); + } catch { + return value; + } + if (!/^https?:$/.test(url.protocol) || url.origin !== PAGE_ORIGIN) + return value; + return APP_ORIGIN + url.pathname + url.search + url.hash; + }; + const isRemote = (value) => { + try { + return new URL(String(value), document.baseURI).origin === APP_ORIGIN; + } catch { + return false; + } + }; + + function redirectRequests() { + const nativeFetch = window.fetch; + window.fetch = (input, init) => + nativeFetch.call( + window, + input instanceof Request + ? new Request(toApp(input.url), input) + : typeof input === 'string' || input instanceof URL + ? toApp(input) + : input, + init, + ); + const nativeOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + return nativeOpen.call(this, method, toApp(url), ...rest); + }; + const nativeBeacon = navigator.sendBeacon?.bind(navigator); + if (nativeBeacon) + navigator.sendBeacon = (url, data) => nativeBeacon(toApp(url), data); + for (const [type, attribute] of [ + [HTMLImageElement, 'src'], + [HTMLMediaElement, 'src'], + [HTMLSourceElement, 'src'], + [HTMLLinkElement, 'href'], + ]) { + const property = Object.getOwnPropertyDescriptor(type.prototype, attribute); + Object.defineProperty(type.prototype, attribute, { + ...property, + set(value) { + property.set.call(this, toApp(value)); + }, + }); + } + // Workers must come from this page's site; one on the app's server + // starts through a small worker here that imports it. + const NativeWorker = window.Worker; + window.Worker = function Worker(url, options) { + const target = toApp(url); + if (!isRemote(target)) return new NativeWorker(url, options); + const source = + options && options.type === 'module' + ? 'import ' + JSON.stringify(String(target)) + ';' + : 'importScripts(' + JSON.stringify(String(target)) + ');'; + return new NativeWorker( + URL.createObjectURL(new Blob([source], { type: 'text/javascript' })), + options, + ); + }; + window.Worker.prototype = NativeWorker.prototype; + // Styles the app adds while it runs name images by root-relative url(). + const rewriteStyle = (style) => { + const text = style.textContent; + const next = text.replace(/url\\((['"]?)\\//g, 'url($1' + APP_ORIGIN + '/'); + if (next !== text) style.textContent = next; + }; + new MutationObserver((records) => { + for (const record of records) { + const target = record.target.nodeName === 'STYLE' ? [record.target] : []; + for (const node of [...target, ...record.addedNodes]) { + if (node.nodeName === 'STYLE') rewriteStyle(node); + // Markup the app adds as HTML text sets src without the setters. + if (node.nodeType !== 1) continue; + for (const element of [node, ...node.querySelectorAll('[src]')]) { + const value = element.getAttribute('src'); + if (value && toApp(value) !== value) + element.setAttribute('src', toApp(value)); + } + } + } + }).observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + } + + /** Point an imported element's own addresses at the app's server. */ + function absolutize(root) { + for (const element of [root, ...root.querySelectorAll('*')]) { + for (const attribute of ['src', 'href', 'poster']) { + const value = element.getAttribute(attribute); + if (value && !value.startsWith('#')) + element.setAttribute(attribute, new URL(value, APP_ORIGIN + '/').href); + } + } + return root; + } + /** * Load God's Eye View into this page. Hosts may not frame other sites, so - * the panel runs the app itself: relative addresses resolve to the app's - * server through , the app's styles and markup are copied in, and - * its scripts run here with the app in inline embed mode. + * the panel runs the app itself: its requests go to the app's server, its + * styles and markup are copied in, and its scripts run here with the app + * in inline embed mode. */ async function startApp() { window.GEV_EMBED_INLINE = true; - const base = document.createElement('base'); - base.href = APP_ORIGIN + '/'; - document.head.prepend(base); + redirectRequests(); const response = await fetch(APP_ORIGIN + '/?embed=1'); if (!response.ok) throw new Error('the app answered ' + response.status); const page = new DOMParser().parseFromString(await response.text(), 'text/html'); for (const node of page.head.querySelectorAll('link[rel="stylesheet"], style')) - document.head.appendChild(document.importNode(node, true)); + document.head.appendChild(absolutize(document.importNode(node, true))); for (const node of [...page.body.childNodes]) { - if (node.nodeName !== 'SCRIPT') - document.body.insertBefore(document.importNode(node, true), status); + if (node.nodeName === 'SCRIPT') continue; + const copy = document.importNode(node, true); + document.body.insertBefore(copy.nodeType === 1 ? absolutize(copy) : copy, status); } for (const original of page.querySelectorAll('script')) { const script = document.createElement('script'); if (original.type) script.type = original.type; if (original.getAttribute('src')) - script.src = new URL(original.getAttribute('src'), base.href).href; + script.src = new URL(original.getAttribute('src'), APP_ORIGIN + '/').href; else script.textContent = original.textContent; document.body.appendChild(script); } @@ -229,10 +340,18 @@ export function createGlobePanelResource({ appUrl }) { socketOrigin(appOrigin), ...PROVIDER_ORIGINS, ], - baseUriDomains: [appOrigin], }, prefersBorder: true, }, + // The same policy under the key OpenAI's clients read. + 'openai/widgetCSP': { + connect_domains: [ + appOrigin, + socketOrigin(appOrigin), + ...PROVIDER_ORIGINS, + ], + resource_domains: [appOrigin, ...PROVIDER_ORIGINS], + }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 49de8afee..350316d38 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -24,7 +24,13 @@ test('the globe panel is an MCP Apps resource that runs the app inside itself', 'ws://localhost:5173', ]); assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com')); - assert.deepEqual(csp.baseUriDomains, ['http://localhost:5173']); + // Hosts may refuse ; the panel sends the app's requests itself. + assert.equal(csp.baseUriDomains, undefined); + assert.deepEqual(resource._meta['openai/widgetCSP'], { + connect_domains: csp.connectDomains, + resource_domains: csp.resourceDomains, + }); + assert.doesNotMatch(resource.text, /createElement\('base'\)/); assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/); assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/); assert.doesNotMatch(resource.text, /