sunpeak 0.20.62 → 0.20.67
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/README.md +3 -0
- package/bin/commands/dev.mjs +88 -46
- package/bin/commands/inspect.mjs +8 -6
- package/bin/lib/inspect/inspect-config.mjs +6 -6
- package/bin/lib/live/chatgpt-page.mjs +40 -25
- package/bin/lib/live/global-setup.mjs +2 -2
- package/bin/lib/live/host-page.mjs +30 -24
- package/bin/lib/live/live-config.mjs +8 -6
- package/bin/lib/test/test-config.mjs +7 -10
- package/bin/lib/test/test-fixtures.mjs +12 -3
- package/dist/chatgpt/index.cjs +1 -1
- package/dist/chatgpt/index.js +1 -1
- package/dist/claude/index.cjs +1 -1
- package/dist/claude/index.js +1 -1
- package/dist/embed.css +2 -2
- package/dist/host/chatgpt/index.cjs +1 -1
- package/dist/host/chatgpt/index.js +1 -1
- package/dist/index.cjs +14 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +12 -7
- package/dist/index.js.map +1 -1
- package/dist/inspector/index.cjs +1 -1
- package/dist/inspector/index.js +1 -1
- package/dist/{inspector-DPJeSQAd.js → inspector-BQx_pKAK.js} +31 -15
- package/dist/{inspector-DPJeSQAd.js.map → inspector-BQx_pKAK.js.map} +1 -1
- package/dist/{inspector-Cl5w_7yQ.cjs → inspector-wz-9eEPo.cjs} +33 -16
- package/dist/{inspector-Cl5w_7yQ.cjs.map → inspector-wz-9eEPo.cjs.map} +1 -1
- package/dist/mcp/index.cjs +14 -7
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +12 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/style.css +7 -6
- package/dist/{use-app-D7-OtXBl.js → use-app-BUrAWLg8.js} +76 -28
- package/dist/{use-app-D7-OtXBl.js.map → use-app-BUrAWLg8.js.map} +1 -1
- package/dist/{use-app-BcSuulay.cjs → use-app-QDHDymli.cjs} +78 -29
- package/dist/{use-app-BcSuulay.cjs.map → use-app-QDHDymli.cjs.map} +1 -1
- package/package.json +16 -16
- package/template/README.md +12 -4
- package/template/dist/albums/albums.html +2 -2
- package/template/dist/albums/albums.json +1 -1
- package/template/dist/carousel/carousel.html +2 -2
- package/template/dist/carousel/carousel.json +1 -1
- package/template/dist/map/map.html +755 -750
- package/template/dist/map/map.json +1 -1
- package/template/dist/review/review.html +1 -1
- package/template/dist/review/review.json +1 -1
- package/template/node_modules/.bin/vite +2 -2
- package/template/node_modules/.bin/vitest +2 -2
- package/template/node_modules/.vite/deps/_metadata.json +4 -4
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js +82 -30
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js +82 -30
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_app-bridge.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js +82 -30
- package/template/node_modules/.vite-mcp/deps/@modelcontextprotocol_ext-apps_react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/@testing-library_react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/_metadata.json +24 -24
- package/template/node_modules/.vite-mcp/deps/{dist-CO4R4Zw4.js → dist-Bd8a6mlN.js} +17 -5
- package/template/node_modules/.vite-mcp/deps/{dist-CO4R4Zw4.js.map → dist-Bd8a6mlN.js.map} +1 -1
- package/template/node_modules/.vite-mcp/deps/embla-carousel-react.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/mapbox-gl.js +14765 -13820
- package/template/node_modules/.vite-mcp/deps/mapbox-gl.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/vitest.js +57 -36
- package/template/node_modules/.vite-mcp/deps/vitest.js.map +1 -1
- package/template/package.json +6 -6
- package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-chatgpt-linux.png +0 -0
- package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-claude-darwin.png +0 -0
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ Server-agnostic MCP testing framework and full-stack MCP App framework.
|
|
|
20
20
|
|
|
21
21
|
MCP Apps are cross-platform, meaning sunpeak is a ChatGPT App framework, Claude Connector framework, and more.
|
|
22
22
|
|
|
23
|
+
ChatGPT apps are now submitted and published as plugins. The app remains an MCP-backed app, so sunpeak's architecture and runtime do not change. The plugin is the package used for local installation, review, and public distribution.
|
|
24
|
+
|
|
23
25
|
```bash
|
|
24
26
|
npx sunpeak new
|
|
25
27
|
```
|
|
@@ -149,4 +151,5 @@ Toggle between hosts, themes, display modes, and device types from the sidebar.
|
|
|
149
151
|
- [MCP Overview](https://sunpeak.ai/docs/mcp-apps/mcp/overview) · [Tools](https://sunpeak.ai/docs/mcp-apps/mcp/tools) · [Resources](https://sunpeak.ai/docs/mcp-apps/mcp/resources)
|
|
150
152
|
- [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps)
|
|
151
153
|
- [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
|
|
154
|
+
- [Build ChatGPT Plugins](https://learn.chatgpt.com/docs/build-plugins) · [Submit ChatGPT Plugins](https://learn.chatgpt.com/docs/submit-plugins)
|
|
152
155
|
- [Troubleshooting](https://sunpeak.ai/docs/app-framework/guides/troubleshooting)
|
package/bin/commands/dev.mjs
CHANGED
|
@@ -66,7 +66,12 @@ async function importFromProject(require, moduleName) {
|
|
|
66
66
|
*
|
|
67
67
|
* When a file changes during a build, the current build is killed and restarted.
|
|
68
68
|
*/
|
|
69
|
-
function startBuildWatcher(
|
|
69
|
+
function startBuildWatcher(
|
|
70
|
+
projectRoot,
|
|
71
|
+
resourcesDir,
|
|
72
|
+
mcpHandle,
|
|
73
|
+
{ skipInitialBuild = false } = {}
|
|
74
|
+
) {
|
|
70
75
|
let activeChild = null;
|
|
71
76
|
const sunpeakBin = join(dirname(fileURLToPath(import.meta.url)), '..', 'sunpeak.js');
|
|
72
77
|
|
|
@@ -120,7 +125,9 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle, { skipInitialBu
|
|
|
120
125
|
});
|
|
121
126
|
console.log('[build] Watching src/resources/ for changes...');
|
|
122
127
|
} catch {
|
|
123
|
-
console.warn(
|
|
128
|
+
console.warn(
|
|
129
|
+
'[build] Could not start file watcher — run "sunpeak build" manually after changes'
|
|
130
|
+
);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -153,7 +160,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
153
160
|
// inspectServer auto-discovers a free port (and doesn't use strictPort,
|
|
154
161
|
// which would crash instead of falling back when port 3000 is busy).
|
|
155
162
|
let port = undefined;
|
|
156
|
-
const portArgIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
|
|
163
|
+
const portArgIndex = args.findIndex((arg) => arg === '--port' || arg === '-p');
|
|
157
164
|
if (portArgIndex !== -1 && args[portArgIndex + 1]) {
|
|
158
165
|
port = parseInt(args[portArgIndex + 1]);
|
|
159
166
|
} else if (process.env.PORT) {
|
|
@@ -167,8 +174,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
167
174
|
const isProdTools = args.includes('--prod-tools');
|
|
168
175
|
const isProdResources = args.includes('--prod-resources');
|
|
169
176
|
|
|
170
|
-
if (isProdTools)
|
|
171
|
-
|
|
177
|
+
if (isProdTools)
|
|
178
|
+
console.log('Prod Tools: MCP tool calls will use real handlers instead of simulation mocks');
|
|
179
|
+
if (isProdResources)
|
|
180
|
+
console.log('Prod Resources: resources will use production-built HTML from dist/');
|
|
172
181
|
|
|
173
182
|
console.log(`Starting dev server${port ? ` on port ${port}` : ''}...`);
|
|
174
183
|
|
|
@@ -195,10 +204,18 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
195
204
|
// Import from installed sunpeak package
|
|
196
205
|
const sunpeakBase = require.resolve('sunpeak').replace(/dist\/index\.(c)?js$/, '');
|
|
197
206
|
sunpeakMcp = await import(pathToFileURL(join(sunpeakBase, 'dist/mcp/index.js')).href);
|
|
198
|
-
sunpeakDiscovery = await import(
|
|
207
|
+
sunpeakDiscovery = await import(
|
|
208
|
+
pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href
|
|
209
|
+
);
|
|
199
210
|
}
|
|
200
211
|
const { runMCPServer } = sunpeakMcp;
|
|
201
|
-
const {
|
|
212
|
+
const {
|
|
213
|
+
findResourceDirs,
|
|
214
|
+
findSimulationFilesFlat,
|
|
215
|
+
findToolFiles,
|
|
216
|
+
extractResourceExport,
|
|
217
|
+
extractToolExport,
|
|
218
|
+
} = sunpeakDiscovery;
|
|
202
219
|
|
|
203
220
|
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
204
221
|
|
|
@@ -243,7 +260,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
243
260
|
const { tool } = await extractToolExport(toolPath);
|
|
244
261
|
toolMap.set(toolName, { tool, path: toolPath });
|
|
245
262
|
} catch (err) {
|
|
246
|
-
console.warn(
|
|
263
|
+
console.warn(
|
|
264
|
+
`Warning: Could not extract metadata from tool "${toolName}" (${toolPath}):\n ${err.message}\n Expected: export const tool: AppToolConfig = { ... }`
|
|
265
|
+
);
|
|
247
266
|
}
|
|
248
267
|
}
|
|
249
268
|
|
|
@@ -285,7 +304,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
285
304
|
});
|
|
286
305
|
}
|
|
287
306
|
} catch (err) {
|
|
288
|
-
console.warn(
|
|
307
|
+
console.warn(
|
|
308
|
+
`Warning: Could not load handler for tool "${toolName}" (${relativePath}):\n ${err.message}`
|
|
309
|
+
);
|
|
289
310
|
}
|
|
290
311
|
}
|
|
291
312
|
|
|
@@ -301,7 +322,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
301
322
|
const toolEntry = toolMap.get(toolName);
|
|
302
323
|
const tool = toolEntry?.tool;
|
|
303
324
|
if (!tool) {
|
|
304
|
-
console.warn(
|
|
325
|
+
console.warn(
|
|
326
|
+
`Warning: Tool "${toolName}" not found for simulation "${simName}". Expected file: src/tools/${toolName}.ts`
|
|
327
|
+
);
|
|
305
328
|
continue;
|
|
306
329
|
}
|
|
307
330
|
|
|
@@ -312,7 +335,9 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
312
335
|
: undefined;
|
|
313
336
|
|
|
314
337
|
if (resourceName && !resourceKey) {
|
|
315
|
-
console.warn(
|
|
338
|
+
console.warn(
|
|
339
|
+
`Warning: No resource found for tool "${toolName}" in simulation "${simName}". Skipping.`
|
|
340
|
+
);
|
|
316
341
|
continue;
|
|
317
342
|
}
|
|
318
343
|
|
|
@@ -326,25 +351,33 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
326
351
|
...simulation,
|
|
327
352
|
...(typeof simulation.tool === 'string' ? { tool: { name: toolName, ...tool } } : {}),
|
|
328
353
|
name: simName,
|
|
329
|
-
...(resourceKey
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
354
|
+
...(resourceKey
|
|
355
|
+
? {
|
|
356
|
+
distPath: join(projectRoot, `dist/${resourceKey}/${resourceKey}.html`),
|
|
357
|
+
srcPath,
|
|
358
|
+
resource: resourceMap.get(resourceKey),
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
334
361
|
// Attach output schema from the tool module (if present)
|
|
335
|
-
...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).outputSchema
|
|
336
|
-
|
|
337
|
-
|
|
362
|
+
...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).outputSchema
|
|
363
|
+
? {
|
|
364
|
+
outputSchema: toolHandlerMap.get(toolName).outputSchema,
|
|
365
|
+
}
|
|
366
|
+
: {}),
|
|
338
367
|
// Attach real handler so Prod Tools mode works at runtime.
|
|
339
368
|
// The --prod-tools flag only sets the default checkbox state; the handler
|
|
340
369
|
// must always be available for when the user toggles it in the sidebar.
|
|
341
|
-
...(toolHandlerMap.has(toolName)
|
|
342
|
-
|
|
343
|
-
|
|
370
|
+
...(toolHandlerMap.has(toolName)
|
|
371
|
+
? {
|
|
372
|
+
handler: toolHandlerMap.get(toolName).handler,
|
|
373
|
+
}
|
|
374
|
+
: {}),
|
|
344
375
|
// Attach the raw Zod shape so the MCP server registers tools with real schemas.
|
|
345
|
-
...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).schema
|
|
346
|
-
|
|
347
|
-
|
|
376
|
+
...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).schema
|
|
377
|
+
? {
|
|
378
|
+
inputSchema: toolHandlerMap.get(toolName).schema,
|
|
379
|
+
}
|
|
380
|
+
: {}),
|
|
348
381
|
});
|
|
349
382
|
}
|
|
350
383
|
|
|
@@ -354,8 +387,8 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
354
387
|
// callServerTool calls to the MCP server because the tool isn't registered.
|
|
355
388
|
for (const [toolName, { tool }] of toolMap) {
|
|
356
389
|
if (tool.resource) continue; // UI tools need simulations for their resource
|
|
357
|
-
const alreadyCovered = simulations.some(
|
|
358
|
-
(s.tool?.name === toolName
|
|
390
|
+
const alreadyCovered = simulations.some(
|
|
391
|
+
(s) => s.tool?.name === toolName || (typeof s.tool === 'string' && s.tool === toolName)
|
|
359
392
|
);
|
|
360
393
|
if (alreadyCovered) continue;
|
|
361
394
|
const handlerInfo = toolHandlerMap.get(toolName);
|
|
@@ -405,12 +438,16 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
405
438
|
return 'console.error("Missing src or component param");';
|
|
406
439
|
}
|
|
407
440
|
|
|
441
|
+
const safeSrcPath = JSON.stringify(srcPath);
|
|
442
|
+
const safeComponentName = JSON.stringify(componentName);
|
|
443
|
+
|
|
408
444
|
return `
|
|
409
|
-
import { createElement } from 'react';
|
|
410
|
-
import { createRoot } from 'react-dom/client';
|
|
411
|
-
import { AppProvider } from 'sunpeak';
|
|
412
|
-
import '/src/styles/globals.css';
|
|
413
|
-
|
|
445
|
+
import { createElement } from 'react';
|
|
446
|
+
import { createRoot } from 'react-dom/client';
|
|
447
|
+
import { AppProvider } from 'sunpeak';
|
|
448
|
+
import '/src/styles/globals.css';
|
|
449
|
+
const ResourceModule = await import(${safeSrcPath});
|
|
450
|
+
const componentName = ${safeComponentName};
|
|
414
451
|
|
|
415
452
|
// Reuse React root across HMR updates to preserve the AppProvider host connection.
|
|
416
453
|
// The host renders this HTML inline (not at a fetchable URL), so location.reload()
|
|
@@ -418,10 +455,13 @@ import * as ResourceModule from '${srcPath}';
|
|
|
418
455
|
const root = import.meta.hot?.data?.root ?? createRoot(document.getElementById('root'));
|
|
419
456
|
if (import.meta.hot) import.meta.hot.data.root = root;
|
|
420
457
|
|
|
421
|
-
const Component = ResourceModule.default || ResourceModule[
|
|
422
|
-
if (!Component) {
|
|
423
|
-
|
|
424
|
-
|
|
458
|
+
const Component = ResourceModule.default || ResourceModule[componentName];
|
|
459
|
+
if (!Component) {
|
|
460
|
+
const error = document.createElement('pre');
|
|
461
|
+
error.style.cssText = 'color:red;padding:16px';
|
|
462
|
+
error.textContent = 'Component not found: ' + componentName + '\\nExports: ' + Object.keys(ResourceModule).join(', ');
|
|
463
|
+
document.getElementById('root').replaceChildren(error);
|
|
464
|
+
} else {
|
|
425
465
|
const appInfo = { name: ${JSON.stringify(pkg.name || 'sunpeak-app')}, version: ${JSON.stringify(pkg.version || '0.1.0')} };
|
|
426
466
|
root.render(
|
|
427
467
|
createElement(AppProvider, { appInfo }, createElement(Component))
|
|
@@ -474,10 +514,7 @@ if (import.meta.hot) {
|
|
|
474
514
|
// resource load discovers new deps (e.g., mapbox-gl, embla-carousel),
|
|
475
515
|
// triggers re-optimization, and reloads all connections — killing
|
|
476
516
|
// any active ChatGPT/Claude iframe connections with ECONNRESET.
|
|
477
|
-
entries: [
|
|
478
|
-
'src/resources/**/*.{ts,tsx}',
|
|
479
|
-
'src/tools/**/*.ts',
|
|
480
|
-
],
|
|
517
|
+
entries: ['src/resources/**/*.{ts,tsx}', 'src/tools/**/*.ts'],
|
|
481
518
|
include: ['react', 'react-dom/client'],
|
|
482
519
|
},
|
|
483
520
|
appType: 'custom',
|
|
@@ -491,8 +528,8 @@ if (import.meta.hot) {
|
|
|
491
528
|
// Skipped under --prod-resources where mcpViteServer is created but unused.
|
|
492
529
|
if (!isProdResources && (resourceDirs.length > 0 || toolFiles.length > 0)) {
|
|
493
530
|
const warmupTargets = [
|
|
494
|
-
...resourceDirs.map(
|
|
495
|
-
`/src/resources/${key}/${basename(resourcePath)}`
|
|
531
|
+
...resourceDirs.map(
|
|
532
|
+
({ key, resourcePath }) => `/src/resources/${key}/${basename(resourcePath)}`
|
|
496
533
|
),
|
|
497
534
|
...toolFiles.map(({ name: toolName }) => `/src/tools/${toolName}.ts`),
|
|
498
535
|
];
|
|
@@ -505,7 +542,9 @@ if (import.meta.hot) {
|
|
|
505
542
|
)
|
|
506
543
|
);
|
|
507
544
|
await mcpViteServer.waitForRequestsIdle();
|
|
508
|
-
console.log(
|
|
545
|
+
console.log(
|
|
546
|
+
`Warmed Vite (${warmupTargets.length} ${warmupTargets.length === 1 ? 'entry' : 'entries'}) in ${Date.now() - warmStart}ms`
|
|
547
|
+
);
|
|
509
548
|
}
|
|
510
549
|
|
|
511
550
|
// Load server config from src/server.ts (if present) for server identity
|
|
@@ -522,7 +561,7 @@ if (import.meta.hot) {
|
|
|
522
561
|
// Extract a display icon from the icons array (first non-dark icon, or first icon)
|
|
523
562
|
const icons = serverMod.server.icons;
|
|
524
563
|
if (Array.isArray(icons) && icons.length > 0) {
|
|
525
|
-
const lightIcon = icons.find(i => !i.theme || i.theme === 'light') ?? icons[0];
|
|
564
|
+
const lightIcon = icons.find((i) => !i.theme || i.theme === 'light') ?? icons[0];
|
|
526
565
|
serverDisplayIcon = lightIcon?.src;
|
|
527
566
|
}
|
|
528
567
|
}
|
|
@@ -598,7 +637,10 @@ if (import.meta.hot) {
|
|
|
598
637
|
const result = await mod.default(args, {});
|
|
599
638
|
const durationMs = Math.round((performance.now() - startTime) * 10) / 10;
|
|
600
639
|
if (typeof result === 'string') {
|
|
601
|
-
return {
|
|
640
|
+
return {
|
|
641
|
+
content: [{ type: 'text', text: result }],
|
|
642
|
+
_meta: { _sunpeak: { requestTimeMs: durationMs } },
|
|
643
|
+
};
|
|
602
644
|
}
|
|
603
645
|
const typed = result ?? {};
|
|
604
646
|
return { ...typed, _meta: { ...typed._meta, _sunpeak: { requestTimeMs: durationMs } } };
|
|
@@ -617,7 +659,7 @@ if (import.meta.hot) {
|
|
|
617
659
|
// Allow running directly
|
|
618
660
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
619
661
|
const args = process.argv.slice(2);
|
|
620
|
-
dev(process.cwd(), args).catch(error => {
|
|
662
|
+
dev(process.cwd(), args).catch((error) => {
|
|
621
663
|
console.error('Error:', error.message);
|
|
622
664
|
process.exit(1);
|
|
623
665
|
});
|
package/bin/commands/inspect.mjs
CHANGED
|
@@ -1032,6 +1032,12 @@ function appendInspectorRequestToken(resourceUrl, requestToken) {
|
|
|
1032
1032
|
}
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
+
function isInspectorRequestTokenValid(req, requestToken) {
|
|
1036
|
+
if (!requestToken) return true;
|
|
1037
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1038
|
+
return url.searchParams.get('__sunpeak_token') === requestToken;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1035
1041
|
function addInspectorRequestTokenToSimulations(simulations, requestToken) {
|
|
1036
1042
|
if (!requestToken || !simulations || typeof simulations !== 'object') return simulations;
|
|
1037
1043
|
return Object.fromEntries(
|
|
@@ -2073,12 +2079,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
|
|
|
2073
2079
|
}
|
|
2074
2080
|
|
|
2075
2081
|
function requireInspectorRequestToken(req, res) {
|
|
2076
|
-
if (
|
|
2077
|
-
const fetchSiteHeader = req.headers['sec-fetch-site'];
|
|
2078
|
-
const fetchSite = Array.isArray(fetchSiteHeader) ? fetchSiteHeader[0] : fetchSiteHeader;
|
|
2079
|
-
if (fetchSite !== 'cross-site') return true;
|
|
2080
|
-
const url = new URL(req.url, 'http://localhost');
|
|
2081
|
-
if (url.searchParams.get('__sunpeak_token') === pluginOpts.requestToken) return true;
|
|
2082
|
+
if (isInspectorRequestTokenValid(req, pluginOpts.requestToken)) return true;
|
|
2082
2083
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2083
2084
|
res.end('Forbidden: missing or invalid inspector request token');
|
|
2084
2085
|
return false;
|
|
@@ -3090,6 +3091,7 @@ export const _securityTestExports = {
|
|
|
3090
3091
|
formatSharedAppContextForModel,
|
|
3091
3092
|
addInspectorRequestTokenToSimulations,
|
|
3092
3093
|
appendInspectorRequestToken,
|
|
3094
|
+
isInspectorRequestTokenValid,
|
|
3093
3095
|
normalizeApiKey,
|
|
3094
3096
|
normalizeModelChatMessages,
|
|
3095
3097
|
normalizeModelAppContext,
|
|
@@ -55,24 +55,24 @@ export function defineInspectConfig(options) {
|
|
|
55
55
|
const { port, sandboxPort } = resolvePorts();
|
|
56
56
|
|
|
57
57
|
// Build the sunpeak inspect command
|
|
58
|
-
const serverArg = server
|
|
58
|
+
const serverArg = shellQuote(server);
|
|
59
59
|
const command = [
|
|
60
60
|
`SUNPEAK_SANDBOX_PORT=${sandboxPort}`,
|
|
61
|
-
`${resolveSunpeakBin()} inspect`,
|
|
61
|
+
`${shellQuote(resolveSunpeakBin())} inspect`,
|
|
62
62
|
`--server ${serverArg}`,
|
|
63
63
|
...(env
|
|
64
64
|
? Object.entries(env).map(([k, v]) => {
|
|
65
65
|
const pair = `${k}=${v}`;
|
|
66
|
-
return
|
|
66
|
+
return `--env ${shellQuote(pair)}`;
|
|
67
67
|
})
|
|
68
68
|
: []),
|
|
69
|
-
...(cwd ? [
|
|
69
|
+
...(cwd ? [`--cwd ${shellQuote(cwd)}`] : []),
|
|
70
70
|
...(headers
|
|
71
71
|
? Object.entries(headers).map(([k, v]) => `--header ${shellQuote(`${k}: ${v}`)}`)
|
|
72
72
|
: []),
|
|
73
|
-
...(simulationsDir ? [`--simulations ${simulationsDir}`] : []),
|
|
73
|
+
...(simulationsDir ? [`--simulations ${shellQuote(simulationsDir)}`] : []),
|
|
74
74
|
`--port ${port}`,
|
|
75
|
-
...(name ? [`--name
|
|
75
|
+
...(name ? [`--name ${shellQuote(name)}`] : []),
|
|
76
76
|
].join(' ');
|
|
77
77
|
|
|
78
78
|
return createBaseConfig({
|
|
@@ -11,7 +11,7 @@ import { HostPage } from './host-page.mjs';
|
|
|
11
11
|
/**
|
|
12
12
|
* All ChatGPT DOM selectors in one place for easy maintenance.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Updated: 2026-07-10 for the documented ChatGPT Plugins flow.
|
|
15
15
|
*/
|
|
16
16
|
const SELECTORS = {
|
|
17
17
|
// Chat interface
|
|
@@ -23,8 +23,9 @@ const SELECTORS = {
|
|
|
23
23
|
loggedInIndicator: '[data-testid="accounts-profile-button"]',
|
|
24
24
|
loginPage: 'button:has-text("Log in")',
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// Plugin directory navigation
|
|
27
|
+
createdByYou:
|
|
28
|
+
'button:has-text("Created by you"), [role="tab"]:has-text("Created by you"), a:has-text("Created by you")',
|
|
28
29
|
refreshButton: 'button:has-text("Refresh")',
|
|
29
30
|
reconnectButton: 'button:has-text("Reconnect")',
|
|
30
31
|
|
|
@@ -40,40 +41,51 @@ const SELECTORS = {
|
|
|
40
41
|
|
|
41
42
|
const URLS = {
|
|
42
43
|
base: 'https://chatgpt.com',
|
|
43
|
-
|
|
44
|
+
plugins: 'https://chatgpt.com/plugins',
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
const PLUGIN_SETUP_HINT =
|
|
48
|
+
'Enable Developer mode from the bottom-left user menu under Settings > Security and login > Developer mode, then add the app from Plugins > +.';
|
|
49
|
+
|
|
46
50
|
export { SELECTORS as CHATGPT_SELECTORS, URLS as CHATGPT_URLS };
|
|
47
51
|
|
|
48
52
|
export class ChatGPTPage extends HostPage {
|
|
49
|
-
get hostId() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
get
|
|
53
|
+
get hostId() {
|
|
54
|
+
return 'chatgpt';
|
|
55
|
+
}
|
|
56
|
+
get hostName() {
|
|
57
|
+
return 'ChatGPT';
|
|
58
|
+
}
|
|
59
|
+
get selectors() {
|
|
60
|
+
return SELECTORS;
|
|
61
|
+
}
|
|
62
|
+
get urls() {
|
|
63
|
+
return URLS;
|
|
64
|
+
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
55
|
-
* Refresh the MCP server connection
|
|
56
|
-
*
|
|
67
|
+
* Refresh the MCP server connection from the ChatGPT Plugins directory.
|
|
68
|
+
* Opens Plugins, finds the developer-mode app, clicks Refresh,
|
|
57
69
|
* and waits for the success/error toast.
|
|
58
70
|
*/
|
|
59
71
|
async refreshMcpServer({ tunnelUrl, appName } = {}) {
|
|
60
|
-
await this.page.goto(URLS.
|
|
72
|
+
await this.page.goto(URLS.plugins, { waitUntil: 'domcontentloaded' });
|
|
61
73
|
await this.page.waitForTimeout(3_000);
|
|
62
74
|
|
|
63
75
|
const found = await this._findAndClickRefresh(appName);
|
|
64
76
|
|
|
65
77
|
if (!found) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
await
|
|
78
|
+
const createdByYou = this.page.locator(SELECTORS.createdByYou).first();
|
|
79
|
+
const hasCreatedByYou = await createdByYou.isVisible().catch(() => false);
|
|
80
|
+
if (hasCreatedByYou) {
|
|
81
|
+
await createdByYou.click();
|
|
70
82
|
await this.page.waitForTimeout(2_000);
|
|
71
83
|
const retryFound = await this._findAndClickRefresh(appName);
|
|
72
84
|
if (!retryFound) {
|
|
73
|
-
await this._screenshotAndThrow('refresh-mcp-
|
|
85
|
+
await this._screenshotAndThrow('refresh-mcp-plugin', tunnelUrl, PLUGIN_SETUP_HINT);
|
|
74
86
|
}
|
|
75
87
|
} else {
|
|
76
|
-
await this._screenshotAndThrow('
|
|
88
|
+
await this._screenshotAndThrow('developer-plugin-not-found', tunnelUrl, PLUGIN_SETUP_HINT);
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
|
|
@@ -82,7 +94,7 @@ export class ChatGPTPage extends HostPage {
|
|
|
82
94
|
if (hasError) {
|
|
83
95
|
throw new Error(
|
|
84
96
|
`MCP server refresh failed in ChatGPT:\n${errorText.trim()}\n\n` +
|
|
85
|
-
|
|
97
|
+
`Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
|
|
86
98
|
);
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -107,13 +119,18 @@ export class ChatGPTPage extends HostPage {
|
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
// Wait for the outer sandbox iframe
|
|
110
|
-
await this.page
|
|
122
|
+
await this.page
|
|
123
|
+
.locator(SELECTORS.mcpAppOuterIframe)
|
|
124
|
+
.first()
|
|
125
|
+
.waitFor({ state: 'attached', timeout: 30_000 });
|
|
111
126
|
|
|
112
127
|
// Wait for the inner frame to appear inside the sandboxed outer iframe.
|
|
113
128
|
// waitForFunction can't cross the sandbox boundary, so use Playwright's frameLocator instead.
|
|
114
129
|
const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
|
|
115
130
|
await outerFrame
|
|
116
|
-
.locator(
|
|
131
|
+
.locator(
|
|
132
|
+
`iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`
|
|
133
|
+
)
|
|
117
134
|
.waitFor({ state: 'attached', timeout: 15_000 });
|
|
118
135
|
|
|
119
136
|
const appFrame = this.getAppIframe();
|
|
@@ -182,13 +199,11 @@ export class ChatGPTPage extends HostPage {
|
|
|
182
199
|
return false;
|
|
183
200
|
};
|
|
184
201
|
|
|
185
|
-
if (await tryClickRefresh()) return true;
|
|
186
|
-
|
|
187
202
|
if (appName) {
|
|
188
203
|
const strategies = [
|
|
189
204
|
() => this.page.getByText(appName, { exact: true }).first(),
|
|
190
|
-
() => this.page.
|
|
191
|
-
() => this.page.
|
|
205
|
+
() => this.page.getByRole('link', { name: appName, exact: true }).first(),
|
|
206
|
+
() => this.page.getByRole('button', { name: appName, exact: true }).first(),
|
|
192
207
|
];
|
|
193
208
|
|
|
194
209
|
for (const getLocator of strategies) {
|
|
@@ -205,6 +220,6 @@ export class ChatGPTPage extends HostPage {
|
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
222
|
|
|
208
|
-
return false;
|
|
223
|
+
return appName ? false : tryClickRefresh();
|
|
209
224
|
}
|
|
210
225
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs exactly once before all workers. Two responsibilities:
|
|
5
5
|
* 1. Authenticate — launch a browser, verify login, wait for user if needed.
|
|
6
|
-
* 2. Refresh MCP server
|
|
6
|
+
* 2. Refresh MCP server: open the host's app management UI and click Refresh so
|
|
7
7
|
* all workers start with pre-loaded resources.
|
|
8
8
|
*
|
|
9
9
|
* Auth approach:
|
|
@@ -150,7 +150,7 @@ export default async function globalSetup() {
|
|
|
150
150
|
// Refresh MCP server in the SAME browser session.
|
|
151
151
|
// This is critical — Cloudflare's cf_clearance cookie is HttpOnly and
|
|
152
152
|
// won't be in the saved storageState. By refreshing here, the cookie
|
|
153
|
-
// is still valid for navigating to
|
|
153
|
+
// is still valid for navigating to the plugin directory.
|
|
154
154
|
//
|
|
155
155
|
// This MUST succeed — if the MCP server isn't reachable or the refresh
|
|
156
156
|
// fails, tests will fail with confusing iframe/timeout errors.
|
|
@@ -23,8 +23,7 @@
|
|
|
23
23
|
/**
|
|
24
24
|
* @typedef {Object} HostUrls
|
|
25
25
|
* @property {string} base - Host base URL (e.g., 'https://chatgpt.com')
|
|
26
|
-
* @property {string}
|
|
27
|
-
* @property {string} loginTestId - Test ID for login detection (if using getByTestId)
|
|
26
|
+
* @property {string} [plugins] - Host plugin or app management URL
|
|
28
27
|
*/
|
|
29
28
|
|
|
30
29
|
export class HostPage {
|
|
@@ -80,7 +79,8 @@ export class HostPage {
|
|
|
80
79
|
if (warnings.length > 0) {
|
|
81
80
|
console.warn(
|
|
82
81
|
`\n⚠️ ${this.hostName} DOM may have changed — update selectors in ${this.hostId}-page.mjs:\n` +
|
|
83
|
-
|
|
82
|
+
warnings.join('\n') +
|
|
83
|
+
'\n'
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -135,14 +135,14 @@ export class HostPage {
|
|
|
135
135
|
// Not logged in. Wait for the user to authenticate in this browser window.
|
|
136
136
|
console.log(
|
|
137
137
|
`\n` +
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
`╔══════════════════════════════════════════════════════════════╗\n` +
|
|
139
|
+
`║ Not logged into ${this.hostName.padEnd(42)}║\n` +
|
|
140
|
+
`║ ║\n` +
|
|
141
|
+
`║ Please log in at: ${this.urls.base.padEnd(39)}║\n` +
|
|
142
|
+
`║ in the browser window that just opened. ║\n` +
|
|
143
|
+
`║ ║\n` +
|
|
144
|
+
`║ Waiting up to 3 minutes... ║\n` +
|
|
145
|
+
`╚══════════════════════════════════════════════════════════════╝\n`
|
|
146
146
|
);
|
|
147
147
|
|
|
148
148
|
// Poll for login — the user may need to pass Cloudflare + enter credentials
|
|
@@ -160,14 +160,14 @@ export class HostPage {
|
|
|
160
160
|
|
|
161
161
|
throw new Error(
|
|
162
162
|
`Login to ${this.hostName} timed out after 3 minutes.\n` +
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
`Please log in at ${this.urls.base} in the browser window and try again.\n` +
|
|
164
|
+
'If the session expired, delete the .auth/ directory and try again.'
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* Refresh the MCP server connection in host
|
|
170
|
-
* Subclasses must implement this
|
|
169
|
+
* Refresh the MCP server connection in the host's app management UI.
|
|
170
|
+
* Subclasses must implement this because each host has a different flow.
|
|
171
171
|
*
|
|
172
172
|
* @param {Object} [options]
|
|
173
173
|
* @param {string} [options.tunnelUrl] - Tunnel URL for error messages
|
|
@@ -228,9 +228,10 @@ export class HostPage {
|
|
|
228
228
|
* Capture a debug screenshot and throw with helpful message.
|
|
229
229
|
* @param {string} context - Context label for the screenshot filename
|
|
230
230
|
* @param {string} [tunnelUrl] - Tunnel URL for the error message
|
|
231
|
+
* @param {string} [setupHint] - Host-specific setup steps
|
|
231
232
|
* @protected
|
|
232
233
|
*/
|
|
233
|
-
async _screenshotAndThrow(context, tunnelUrl) {
|
|
234
|
+
async _screenshotAndThrow(context, tunnelUrl, setupHint) {
|
|
234
235
|
const screenshotPath = `/tmp/sunpeak-live-debug-${this.hostId}-${context}.png`;
|
|
235
236
|
try {
|
|
236
237
|
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
@@ -241,29 +242,34 @@ export class HostPage {
|
|
|
241
242
|
|
|
242
243
|
try {
|
|
243
244
|
const buttons = await this.page.locator('button').allTextContents();
|
|
244
|
-
console.error('Visible buttons on page:', buttons.filter(t => t.trim()).join(', '));
|
|
245
|
+
console.error('Visible buttons on page:', buttons.filter((t) => t.trim()).join(', '));
|
|
245
246
|
} catch {
|
|
246
247
|
// Best effort
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
throw new Error(
|
|
250
|
-
`Could not find Refresh/Reconnect
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
`Could not find Refresh/Reconnect in ${this.hostName} app management.\n` +
|
|
252
|
+
`Make sure your MCP server is added to ${this.hostName}` +
|
|
253
|
+
(tunnelUrl ? ` with URL: ${tunnelUrl}/mcp` : '') +
|
|
254
|
+
(setupHint ? `\n${setupHint}` : '') +
|
|
255
|
+
`\n\nDebug screenshot: ${screenshotPath}`
|
|
254
256
|
);
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
/**
|
|
258
260
|
* Wait for a toast/alert banner and check for errors.
|
|
259
|
-
* Many hosts show success/error toasts after
|
|
261
|
+
* Many hosts show success/error toasts after app management actions.
|
|
260
262
|
* @param {Object} [options]
|
|
261
263
|
* @param {string} [options.alertSelector='[role="alert"]'] - Selector for toast elements
|
|
262
264
|
* @param {number} [options.timeout=30000] - Max time to wait
|
|
263
265
|
* @param {number} [options.minTextLength=5] - Minimum text length to consider as a real toast
|
|
264
266
|
* @protected
|
|
265
267
|
*/
|
|
266
|
-
async _waitForToast({
|
|
268
|
+
async _waitForToast({
|
|
269
|
+
alertSelector = '[role="alert"]',
|
|
270
|
+
timeout = 30_000,
|
|
271
|
+
minTextLength = 5,
|
|
272
|
+
} = {}) {
|
|
267
273
|
try {
|
|
268
274
|
await this.page.waitForFunction(
|
|
269
275
|
({ selector, minLen }) => {
|
|
@@ -275,7 +281,7 @@ export class HostPage {
|
|
|
275
281
|
return false;
|
|
276
282
|
},
|
|
277
283
|
{ selector: alertSelector, minLen: minTextLength },
|
|
278
|
-
{ timeout }
|
|
284
|
+
{ timeout }
|
|
279
285
|
);
|
|
280
286
|
} catch {
|
|
281
287
|
console.warn('No toast detected — assuming success.');
|