@ggui-ai/dev-stack 0.1.0-rc.1 → 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev-server/sandbox-proxy.d.ts +46 -0
- package/dist/dev-server/sandbox-proxy.d.ts.map +1 -0
- package/dist/dev-server/sandbox-proxy.js +270 -0
- package/dist/hub-preview/client.js +13898 -1990
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/package.json +7 -7
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link startSandboxProxyServer}.
|
|
3
|
+
*/
|
|
4
|
+
export interface SandboxProxyServerOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Port to bind. `0` asks the OS for a free port — the chosen port
|
|
7
|
+
* is reflected in the returned `url`. Tests pass `0`; samples pass
|
|
8
|
+
* a fixed port so the AppRenderer config is static.
|
|
9
|
+
*/
|
|
10
|
+
readonly port: number;
|
|
11
|
+
/**
|
|
12
|
+
* Bind address. Defaults to `127.0.0.1` — loopback-only. Pass
|
|
13
|
+
* `'0.0.0.0'` only if LAN access is required.
|
|
14
|
+
*/
|
|
15
|
+
readonly host?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Handle to a running sandbox proxy server.
|
|
19
|
+
*/
|
|
20
|
+
export interface SandboxProxyServerHandle {
|
|
21
|
+
/**
|
|
22
|
+
* Absolute URL the AppRenderer's `sandbox.url` prop should point at.
|
|
23
|
+
* Includes scheme, host, and port. Path is `/sandbox.html` — the
|
|
24
|
+
* spec-canonical sandbox entry point.
|
|
25
|
+
*/
|
|
26
|
+
readonly url: string;
|
|
27
|
+
/** Actual bound port (resolved when `options.port` was `0`). */
|
|
28
|
+
readonly port: number;
|
|
29
|
+
/** Stops the server and releases the port. */
|
|
30
|
+
readonly close: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start an HTTP server serving `sandbox.html` for use as
|
|
34
|
+
* `<AppRenderer>`'s `sandbox.url`. The server has exactly two
|
|
35
|
+
* behaviors:
|
|
36
|
+
*
|
|
37
|
+
* - `GET /` and `GET /sandbox.html` → sandbox HTML + CSP header
|
|
38
|
+
* derived from `?csp=<urlencoded-json>` (mirrors upstream
|
|
39
|
+
* basic-host serve.ts).
|
|
40
|
+
* - Anything else → 404.
|
|
41
|
+
*
|
|
42
|
+
* Returns immediately once the server is listening; the handle's
|
|
43
|
+
* `close()` releases the port.
|
|
44
|
+
*/
|
|
45
|
+
export declare function startSandboxProxyServer(options: SandboxProxyServerOptions): Promise<SandboxProxyServerHandle>;
|
|
46
|
+
//# sourceMappingURL=sandbox-proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox-proxy.d.ts","sourceRoot":"","sources":["../../src/dev-server/sandbox-proxy.ts"],"names":[],"mappings":"AAoDA;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAuKD;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,wBAAwB,CAAC,CAuEnC"}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `startSandboxProxyServer` — MCP-Apps spec-compliant sandbox proxy host
|
|
3
|
+
* for `<AppRenderer>`.
|
|
4
|
+
*
|
|
5
|
+
* # Why this exists
|
|
6
|
+
*
|
|
7
|
+
* The MCP Apps spec (specification/2026-01-26/apps.mdx, double-iframe
|
|
8
|
+
* sandbox architecture) mandates that the host and the sandbox iframe
|
|
9
|
+
* live on DIFFERENT origins. The host wraps the sandbox iframe; the
|
|
10
|
+
* sandbox iframe wraps the untrusted app HTML. Separating origins
|
|
11
|
+
* means a compromise of the app cannot reach host APIs via
|
|
12
|
+
* same-origin DOM access.
|
|
13
|
+
*
|
|
14
|
+
* `@mcp-ui/client`'s `<AppRenderer>` accepts a `sandbox: { url }` prop
|
|
15
|
+
* pointing at a public URL serving `sandbox.html`. We ship that file +
|
|
16
|
+
* its message-relay JS as a self-contained HTML string and serve it
|
|
17
|
+
* over plain Node `http` on a separate port. Samples (and any consumer
|
|
18
|
+
* of the dev-stack) boot this alongside their agent server.
|
|
19
|
+
*
|
|
20
|
+
* # Reference impl
|
|
21
|
+
*
|
|
22
|
+
* Adapted from
|
|
23
|
+
* `github.com/modelcontextprotocol/ext-apps/examples/basic-host/`
|
|
24
|
+
* (sandbox.html + src/sandbox.ts + serve.ts). Inlined into a single
|
|
25
|
+
* HTML so the dev-stack doesn't need to bundle a static asset.
|
|
26
|
+
*
|
|
27
|
+
* # Security
|
|
28
|
+
*
|
|
29
|
+
* - CSP set via HTTP headers per `?csp=<urlencoded-json>` query
|
|
30
|
+
* (tamper-proof; meta tags can be overridden by inline scripts).
|
|
31
|
+
* - Sanitizes CSP domain entries to block `;`, newlines, quotes,
|
|
32
|
+
* spaces — defends against directive injection.
|
|
33
|
+
* - Binds to `127.0.0.1` by default. Pass `host: '0.0.0.0'` only
|
|
34
|
+
* when LAN exposure is required.
|
|
35
|
+
*
|
|
36
|
+
* # Caller pattern
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { startSandboxProxyServer } from '@ggui-ai/dev-stack';
|
|
40
|
+
* const proxy = await startSandboxProxyServer({ port: 7790 });
|
|
41
|
+
* console.log('Sandbox proxy:', proxy.url);
|
|
42
|
+
* // ... pass proxy.url to AppRenderer's sandbox.url prop ...
|
|
43
|
+
* await proxy.close();
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
import { createServer, } from 'node:http';
|
|
47
|
+
/**
|
|
48
|
+
* Reject CSP entries containing directive-injection characters
|
|
49
|
+
* (`;`, newlines, quotes, spaces). Returns the kept entries.
|
|
50
|
+
*/
|
|
51
|
+
function sanitizeCspDomains(domains) {
|
|
52
|
+
if (!domains)
|
|
53
|
+
return [];
|
|
54
|
+
return domains.filter((d) => typeof d === 'string' && !/[;\r\n'" ]/.test(d));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the `Content-Security-Policy` header value from an
|
|
58
|
+
* (already-sanitized) CSP record. Mirrors `serve.ts` in the upstream
|
|
59
|
+
* basic-host example so behavior matches the spec reference.
|
|
60
|
+
*/
|
|
61
|
+
function buildCspHeader(csp) {
|
|
62
|
+
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(' ');
|
|
63
|
+
const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(' ');
|
|
64
|
+
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(' ');
|
|
65
|
+
const baseUriDomains = sanitizeCspDomains(csp?.baseUriDomains).join(' ');
|
|
66
|
+
const directives = [
|
|
67
|
+
"default-src 'self' 'unsafe-inline'",
|
|
68
|
+
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
|
|
69
|
+
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
|
|
70
|
+
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
71
|
+
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
72
|
+
`media-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
73
|
+
`connect-src 'self' ${connectDomains}`.trim(),
|
|
74
|
+
`worker-src 'self' blob: ${resourceDomains}`.trim(),
|
|
75
|
+
frameDomains.length > 0
|
|
76
|
+
? `frame-src ${frameDomains}`
|
|
77
|
+
: "frame-src 'none'",
|
|
78
|
+
"object-src 'none'",
|
|
79
|
+
baseUriDomains.length > 0
|
|
80
|
+
? `base-uri ${baseUriDomains}`
|
|
81
|
+
: "base-uri 'none'",
|
|
82
|
+
];
|
|
83
|
+
return directives.join('; ');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The self-contained sandbox.html body. Inlines the message-relay JS
|
|
87
|
+
* from `examples/basic-host/src/sandbox.ts` so we ship one file and
|
|
88
|
+
* don't need a build step. The script is a verbatim port that:
|
|
89
|
+
*
|
|
90
|
+
* 1. Asserts iframe isolation (throws if it can reach `window.top`).
|
|
91
|
+
* 2. Creates an inner iframe sandboxed with `allow-scripts
|
|
92
|
+
* allow-same-origin allow-forms`.
|
|
93
|
+
* 3. Intercepts `ui/notifications/sandbox-resource-ready` from the
|
|
94
|
+
* parent to write the app HTML into the inner iframe.
|
|
95
|
+
* 4. Relays every other postMessage bidirectionally between parent
|
|
96
|
+
* and inner.
|
|
97
|
+
* 5. Posts `ui/notifications/sandbox-proxy-ready` once attached.
|
|
98
|
+
*/
|
|
99
|
+
const SANDBOX_HTML = `<!doctype html>
|
|
100
|
+
<html>
|
|
101
|
+
<head>
|
|
102
|
+
<meta charset="utf-8" />
|
|
103
|
+
<meta name="color-scheme" content="light dark">
|
|
104
|
+
<title>ggui sandbox proxy</title>
|
|
105
|
+
<style>
|
|
106
|
+
html, body { margin: 0; height: 100vh; width: 100vw; background-color: transparent; }
|
|
107
|
+
body { display: flex; flex-direction: column; }
|
|
108
|
+
* { box-sizing: border-box; }
|
|
109
|
+
iframe {
|
|
110
|
+
background-color: transparent;
|
|
111
|
+
border: 0px none transparent;
|
|
112
|
+
padding: 0px;
|
|
113
|
+
overflow: hidden;
|
|
114
|
+
flex-grow: 1;
|
|
115
|
+
color-scheme: inherit;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<script>
|
|
121
|
+
(function(){
|
|
122
|
+
'use strict';
|
|
123
|
+
if (window.self === window.top) {
|
|
124
|
+
throw new Error('This file is only to be used in an iframe sandbox.');
|
|
125
|
+
}
|
|
126
|
+
if (!document.referrer) {
|
|
127
|
+
throw new Error('No referrer, cannot validate embedding site.');
|
|
128
|
+
}
|
|
129
|
+
var EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
|
130
|
+
var OWN_ORIGIN = new URL(window.location.href).origin;
|
|
131
|
+
// Security self-test: top access MUST throw (sandbox attribute strips same-origin).
|
|
132
|
+
try {
|
|
133
|
+
window.top.alert('If you see this, the sandbox is not setup securely.');
|
|
134
|
+
throw 'FAIL';
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e === 'FAIL') {
|
|
137
|
+
throw new Error('The sandbox is not setup securely.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Inner iframe — the untrusted app HTML lands here.
|
|
141
|
+
var inner = document.createElement('iframe');
|
|
142
|
+
inner.style = 'width:100%; height:100%; border:none;';
|
|
143
|
+
inner.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
|
|
144
|
+
document.body.appendChild(inner);
|
|
145
|
+
var RESOURCE_READY = 'ui/notifications/sandbox-resource-ready';
|
|
146
|
+
var PROXY_READY = 'ui/notifications/sandbox-proxy-ready';
|
|
147
|
+
window.addEventListener('message', function(event) {
|
|
148
|
+
if (event.source === window.parent) {
|
|
149
|
+
if (event.origin !== EXPECTED_HOST_ORIGIN) {
|
|
150
|
+
console.error('[Sandbox] Rejecting parent message from unexpected origin:', event.origin);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (event.data && event.data.method === RESOURCE_READY) {
|
|
154
|
+
var params = event.data.params || {};
|
|
155
|
+
var html = params.html;
|
|
156
|
+
var sandboxAttr = params.sandbox;
|
|
157
|
+
if (typeof sandboxAttr === 'string') {
|
|
158
|
+
inner.setAttribute('sandbox', sandboxAttr);
|
|
159
|
+
}
|
|
160
|
+
if (typeof html === 'string') {
|
|
161
|
+
var doc = inner.contentDocument || (inner.contentWindow && inner.contentWindow.document);
|
|
162
|
+
if (doc) {
|
|
163
|
+
doc.open();
|
|
164
|
+
doc.write(html);
|
|
165
|
+
doc.close();
|
|
166
|
+
} else {
|
|
167
|
+
inner.srcdoc = html;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
if (inner && inner.contentWindow) {
|
|
172
|
+
inner.contentWindow.postMessage(event.data, '*');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (event.source === inner.contentWindow) {
|
|
176
|
+
if (event.origin !== OWN_ORIGIN) {
|
|
177
|
+
console.error('[Sandbox] Rejecting inner message from unexpected origin:', event.origin);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
window.parent.postMessage({
|
|
184
|
+
jsonrpc: '2.0',
|
|
185
|
+
method: PROXY_READY,
|
|
186
|
+
params: {},
|
|
187
|
+
}, EXPECTED_HOST_ORIGIN);
|
|
188
|
+
})();
|
|
189
|
+
</script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>`;
|
|
192
|
+
/**
|
|
193
|
+
* Start an HTTP server serving `sandbox.html` for use as
|
|
194
|
+
* `<AppRenderer>`'s `sandbox.url`. The server has exactly two
|
|
195
|
+
* behaviors:
|
|
196
|
+
*
|
|
197
|
+
* - `GET /` and `GET /sandbox.html` → sandbox HTML + CSP header
|
|
198
|
+
* derived from `?csp=<urlencoded-json>` (mirrors upstream
|
|
199
|
+
* basic-host serve.ts).
|
|
200
|
+
* - Anything else → 404.
|
|
201
|
+
*
|
|
202
|
+
* Returns immediately once the server is listening; the handle's
|
|
203
|
+
* `close()` releases the port.
|
|
204
|
+
*/
|
|
205
|
+
export function startSandboxProxyServer(options) {
|
|
206
|
+
const host = options.host ?? '127.0.0.1';
|
|
207
|
+
const server = createServer((req, res) => {
|
|
208
|
+
// Resolve URL relative to a synthetic base so we can use
|
|
209
|
+
// URL parsing (req.url is path+query only).
|
|
210
|
+
const url = new URL(req.url ?? '/', 'http://placeholder');
|
|
211
|
+
if (req.method === 'GET' &&
|
|
212
|
+
(url.pathname === '/' || url.pathname === '/sandbox.html')) {
|
|
213
|
+
let csp;
|
|
214
|
+
const cspParam = url.searchParams.get('csp');
|
|
215
|
+
if (cspParam !== null) {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(cspParam);
|
|
218
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
219
|
+
csp = parsed;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Ignore malformed CSP — fall through to the default
|
|
224
|
+
// (`'self' 'unsafe-inline'`) header. Visible in the
|
|
225
|
+
// browser's console as a CSP violation if the app loads
|
|
226
|
+
// external resources.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
res.setHeader('Content-Security-Policy', buildCspHeader(csp));
|
|
230
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
231
|
+
res.setHeader('Pragma', 'no-cache');
|
|
232
|
+
res.setHeader('Expires', '0');
|
|
233
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
234
|
+
// CORS: AppRenderer fetches the sandbox HTML cross-origin
|
|
235
|
+
// (parent vs. sandbox-proxy origin); the browser still
|
|
236
|
+
// navigates the iframe even without explicit CORS, but the
|
|
237
|
+
// header is harmless and unblocks any future fetch() probes.
|
|
238
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
239
|
+
res.statusCode = 200;
|
|
240
|
+
res.end(SANDBOX_HTML);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
res.statusCode = 404;
|
|
244
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
245
|
+
res.end('sandbox proxy: only GET /sandbox.html is served here\n');
|
|
246
|
+
});
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
server.once('error', reject);
|
|
249
|
+
server.listen(options.port, host, () => {
|
|
250
|
+
const addr = server.address();
|
|
251
|
+
if (addr === null || typeof addr === 'string') {
|
|
252
|
+
reject(new Error(`startSandboxProxyServer: address() returned ${String(addr)}; expected AddressInfo`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const url = `http://${host}:${addr.port}/sandbox.html`;
|
|
256
|
+
resolve({
|
|
257
|
+
url,
|
|
258
|
+
port: addr.port,
|
|
259
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
260
|
+
server.close((err) => {
|
|
261
|
+
if (err)
|
|
262
|
+
rejectClose(err);
|
|
263
|
+
else
|
|
264
|
+
resolveClose();
|
|
265
|
+
});
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|