@ggui-ai/dev-stack 0.1.0-rc.3 → 0.2.0-alpha.3

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.
@@ -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
+ }