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, /