@ggui-ai/iframe-runtime 0.9.0 → 0.10.0

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.
@@ -30,6 +30,19 @@ export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGgui
30
30
  * so the runtime can mount without any postMessage round-trip.
31
31
  */
32
32
  export declare function parseMetaFromGlobal(): McpAppAiGguiMetaParseResult;
33
+ /**
34
+ * The `ui://ggui/render/…` LOCATOR a tool result publishes when a server
35
+ * runs the read-plane-only posture (`withholdResultMeta`, guuey#209 C3 /
36
+ * ggui#537): the bootstrap material is absent, but the view's durable
37
+ * identity rides `structuredContent.resourceUri` (ggui-aware hosts) and
38
+ * the spec-canonical `_meta.ui.resourceUri` (MCP Apps hosts) — same
39
+ * value, either slot. The runtime resolves such a result through the
40
+ * read-plane door (`app.readServerResource` → the per-render
41
+ * self-contained shell → its inlined envelope). `null` for anything else
42
+ * — a result that carries the slice inline never needs this, and a
43
+ * result with neither is not a view.
44
+ */
45
+ export declare function extractLocatorFromToolResult(params: unknown): string | null;
33
46
  /**
34
47
  * Parse the render slice from a `ui/notifications/tool-result`
35
48
  * postMessage params payload. `params` is a `CallToolResult` per the
@@ -274,6 +274,11 @@ function projectMeta(meta, hasStaticContent) {
274
274
  : {}),
275
275
  ...(meta.codeUrl !== undefined ? { codeUrl: meta.codeUrl } : {}),
276
276
  ...(meta.codeHash !== undefined ? { codeHash: meta.codeHash } : {}),
277
+ // Strict-CSP module-variant twin (ggui#522 slice 2). The protocol
278
+ // parser already enforces the with-a-raw-carrier pairing.
279
+ ...(meta.codeModuleUrl !== undefined
280
+ ? { codeModuleUrl: meta.codeModuleUrl }
281
+ : {}),
277
282
  ...(meta.codeB64 !== undefined ? { codeB64: meta.codeB64 } : {}),
278
283
  ...(meta.kind !== undefined ? { kind: meta.kind } : {}),
279
284
  };
@@ -349,6 +354,29 @@ export function parseMetaFromGlobal() {
349
354
  }
350
355
  return validateMeta(parsed.meta);
351
356
  }
357
+ /**
358
+ * The `ui://ggui/render/…` LOCATOR a tool result publishes when a server
359
+ * runs the read-plane-only posture (`withholdResultMeta`, guuey#209 C3 /
360
+ * ggui#537): the bootstrap material is absent, but the view's durable
361
+ * identity rides `structuredContent.resourceUri` (ggui-aware hosts) and
362
+ * the spec-canonical `_meta.ui.resourceUri` (MCP Apps hosts) — same
363
+ * value, either slot. The runtime resolves such a result through the
364
+ * read-plane door (`app.readServerResource` → the per-render
365
+ * self-contained shell → its inlined envelope). `null` for anything else
366
+ * — a result that carries the slice inline never needs this, and a
367
+ * result with neither is not a view.
368
+ */
369
+ export function extractLocatorFromToolResult(params) {
370
+ if (!isPlainObject(params))
371
+ return null;
372
+ const sc = params['structuredContent'];
373
+ const fromStructured = isPlainObject(sc) && typeof sc['resourceUri'] === 'string' ? sc['resourceUri'] : null;
374
+ const meta = params['_meta'];
375
+ const ui = isPlainObject(meta) ? meta['ui'] : undefined;
376
+ const fromUi = isPlainObject(ui) && typeof ui['resourceUri'] === 'string' ? ui['resourceUri'] : null;
377
+ const uri = fromStructured ?? fromUi;
378
+ return uri !== null && uri.startsWith('ui://ggui/render/') ? uri : null;
379
+ }
352
380
  /**
353
381
  * Parse the render slice from a `ui/notifications/tool-result`
354
382
  * postMessage params payload. `params` is a `CallToolResult` per the
@@ -41,6 +41,18 @@ export interface ReactRootMountOptions {
41
41
  readonly id?: string;
42
42
  readonly componentCode: string;
43
43
  readonly props?: Record<string, unknown>;
44
+ /**
45
+ * Strict-CSP module variant of `componentCode` (ggui#522 slice 2):
46
+ * a server-side import-rewritten twin of the SAME bytes, directly
47
+ * `import()`able (its imports resolve to static shim assets on an
48
+ * allowed origin — no `data:`, no `blob:`, no eval). Tried FIRST;
49
+ * any failure falls through to the blob/inline ladder on the raw
50
+ * bytes. Callers MUST pass it only when it names exactly these
51
+ * `componentCode` bytes — a stale variant would paint the WRONG
52
+ * component, not fail (the runtime's dispatcher guards this by
53
+ * attaching it to the inline seed only).
54
+ */
55
+ readonly codeModuleUrl?: string;
44
56
  };
45
57
  readonly themeId?: string;
46
58
  /**
@@ -235,6 +235,30 @@ export async function mountReactRoot(container, opts) {
235
235
  ? { gadgetPackages: currentOpts.gadgetPackages }
236
236
  : {}),
237
237
  }));
238
+ // Asset-module path (ggui#522 slice 2) — FIRST when the mount
239
+ // carries the strict-CSP variant URL: a plain https module import,
240
+ // legal under `script-src` origins alone, no scheme sources, no
241
+ // eval. Under a strict host CSP it is the ONLY path that works;
242
+ // under a permissive one it costs a CDN round-trip on cold cache
243
+ // and nothing on warm (the URL is immutable). Any failure — CSP
244
+ // block, network fault, a 404 from a redeployed variant family —
245
+ // falls through to the raw-bytes ladder below. No document latch
246
+ // on failure: unlike the per-evaluation blob probe, this branch
247
+ // runs only when componentCode changes (rare — usually once per
248
+ // mount), so the retry cost is one fetch, and the failure classes
249
+ // (transient network vs permanent CSP) are indistinguishable in
250
+ // the import error anyway.
251
+ const moduleUrl = currentOpts.render.codeModuleUrl;
252
+ if (moduleUrl !== undefined) {
253
+ try {
254
+ const mod = (await import(
255
+ /* webpackIgnore: true */ /* @vite-ignore */ moduleUrl));
256
+ return pick(mod);
257
+ }
258
+ catch {
259
+ // Fall through — the raw bytes are the authoritative carrier.
260
+ }
261
+ }
238
262
  // Once URL-scheme module loading has been proven blocked in this
239
263
  // document, go straight to inline execution — the CSP verdict
240
264
  // cannot change within the document's lifetime.
@@ -278,9 +302,25 @@ export async function mountReactRoot(container, opts) {
278
302
  }
279
303
  }
280
304
  function renderTree(opts) {
281
- const themeCss = opts.themeId
305
+ let themeCss = opts.themeId
282
306
  ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
283
307
  : getScopedCssTokens(scopeClass, opts.themeMode);
308
+ if (opts.appTheme) {
309
+ const decls = Object.entries(opts.appTheme.cssVariables)
310
+ .map(([k, v]) => `${k}: ${v};`)
311
+ .join('');
312
+ // The overlay must be merged HERE, into the scoped style that
313
+ // mounts INSIDE the scope div — the `:root` append below is
314
+ // cascade-dead for tree content: the in-scope block sits later
315
+ // in document order than anything in `<head>`, so at equal
316
+ // specificity the base scoped tokens always win over a head-level
317
+ // override (browser-verified in rnd/gen-ui/beauty/experiments/001,
318
+ // the probe sequence that found this). Appended AFTER the base
319
+ // block and BEFORE `cssOverrides`, so: base < operator overlay <
320
+ // explicit overrides. Values validated injection-safe upstream
321
+ // (same guarantee the `:root` append cites).
322
+ themeCss += `.${scopeClass}{${decls}}`;
323
+ }
284
324
  // Also inject theme CSS at `:root` on `document.head`. The scoped
285
325
  // injection above isolates token resolution to the React tree, but
286
326
  // the embedding shell's body styles (font-family, color, background)
@@ -298,10 +338,12 @@ export async function mountReactRoot(container, opts) {
298
338
  const decls = Object.entries(opts.appTheme.cssVariables)
299
339
  .map(([k, v]) => `${k}: ${v};`)
300
340
  .join('');
301
- // Appended AFTER the base block → the per-app overlay wins; it is a
302
- // partial set (unset vars keep their token defaults). CSS custom
303
- // properties at :root inherit into the scoped React tree, so this
304
- // alone is sufficient for the component to resolve `var(--ggui-*)`.
341
+ // BODY-CHROME ONLY. This `:root` append themes the embedding
342
+ // shell's body styles (font/color/background outside the scope
343
+ // div). It does NOT reach tree content — the scoped in-body
344
+ // token block wins by document order (see the scoped merge in
345
+ // `renderTree`, and rnd/gen-ui/beauty/experiments/001 for the
346
+ // probe that falsified the old "inherits into the tree" claim).
305
347
  // Values were validated injection-safe upstream (write-side
306
348
  // `appThemeSchema` + the wire parser re-validates), so the
307
349
  // string-join here is safe — do NOT re-sanitize.
@@ -29,6 +29,15 @@ export interface RenderItemOptions {
29
29
  * this. STDLIB `@ggui-ai/gadgets` is always rewritten regardless.
30
30
  */
31
31
  readonly gadgetPackages?: readonly string[];
32
+ /**
33
+ * Strict-CSP module variant of the render's `componentCode`
34
+ * (ggui#522 slice 2), forwarded to
35
+ * {@link ReactRootMountOptions.render.codeModuleUrl}. The CALLER
36
+ * owns the bytes↔URL association: pass it only when it names
37
+ * exactly `render.componentCode`'s bytes (the runtime attaches it
38
+ * to the inline seed mount only — a WS-delivered render drops it).
39
+ */
40
+ readonly codeModuleUrl?: string;
32
41
  /** Theme id passed to ReactComponentRenderer for scoped CSS. */
33
42
  readonly themeId?: string;
34
43
  /**
@@ -169,6 +169,9 @@ export async function mountRender(container, opts) {
169
169
  ...(render.props !== undefined
170
170
  ? { props: render.props }
171
171
  : {}),
172
+ ...(currentOpts.codeModuleUrl !== undefined
173
+ ? { codeModuleUrl: currentOpts.codeModuleUrl }
174
+ : {}),
172
175
  },
173
176
  ...(gadgetPackages !== undefined ? { gadgetPackages } : {}),
174
177
  ...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
@@ -234,6 +237,9 @@ export async function mountRender(container, opts) {
234
237
  ...(render.props !== undefined
235
238
  ? { props: render.props }
236
239
  : {}),
240
+ ...(next.codeModuleUrl !== undefined
241
+ ? { codeModuleUrl: next.codeModuleUrl }
242
+ : {}),
237
243
  },
238
244
  ...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
239
245
  ...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
package/dist/runtime.d.ts CHANGED
@@ -1,3 +1,30 @@
1
+ /**
2
+ * `@ggui-ai/iframe-runtime` iframe runtime entry.
3
+ *
4
+ * This is the file esbuild bundles into `dist/iframe-runtime.js`. The
5
+ * thin-shell HTML loads it via `<script type="module" src=".../iframe-runtime.js">`;
6
+ * on import the side-effects below take over: build a status DOM,
7
+ * postMessage `ui/initialize` to the parent, parse the bootstrap, open
8
+ * the WebSocket, run the version handshake, and mount the render
9
+ * placeholder.
10
+ *
11
+ * Boot sequence:
12
+ * - Boot from the `_meta["ai.ggui/render"]` slice received via
13
+ * `ui/initialize`.
14
+ * - Open the WebSocket, run the subscribe handshake.
15
+ * - Mount the render — either a structural placeholder or, when
16
+ * the renderer hooks are wired, a React mount of `componentCode`.
17
+ *
18
+ * The runtime advertises its build version via a post-`ui/initialize`
19
+ * notification (`ggui:renderer-ready`).
20
+ *
21
+ * Failure surfacing: every parse / handshake failure goes to two
22
+ * places — (1) the in-iframe status line (operator-visible) and
23
+ * (2) a parent-bound `postMessage({type:'ggui:bootstrap-failed', ...})`.
24
+ * The post-message envelope is consumed by the `<McpAppIframe>` host
25
+ * wrapper, which routes it to the host's `onError` callback.
26
+ */
27
+ import './zod-jitless.js';
1
28
  import type { DrainAckPayload, JsonValue, JsonObject, GguiSession } from '@ggui-ai/protocol';
2
29
  import type { WebSocketMessage } from '@ggui-ai/protocol/transport/websocket';
3
30
  import { type McpAppAiGguiRenderMeta, type McpAppBootstrapFailedMessage, type McpAppRendererReadyMessage } from '@ggui-ai/protocol/integrations/mcp-apps';
@@ -47,6 +74,29 @@ export type ConnectAppResult = {
47
74
  * without invoking the full boot pipeline.
48
75
  */
49
76
  export declare function setCurrentApp(app: App): void;
77
+ /**
78
+ * The host's announced color mode, read from the connected App's
79
+ * pre-merged hostContext (`ui/initialize` result, kept current by the
80
+ * App across `host-context-changed`). `undefined` when no App is
81
+ * connected, the host sent no `theme`, or it sent something outside
82
+ * the spec's `'light' | 'dark'` literals (dropped, never guessed —
83
+ * the same tolerant posture as `projectHostContext`).
84
+ *
85
+ * This is the INPUT leg of host-theme adaptation
86
+ * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
87
+ * `hostContext.theme` alone reaches only `data-theme` +
88
+ * `color-scheme` via `applyDocumentTheme` — nothing in the token
89
+ * pipeline keys off it, so a dark host painted the light ladder
90
+ * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
91
+ * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
92
+ * every operator layer (live provider > per-render override > app
93
+ * default > server default) still wins by being stamped; the host
94
+ * fills the gap where no operator had an opinion.
95
+ *
96
+ * @internal — exported for unit tests (`setCurrentApp` injects the
97
+ * App); production caller is `buildOpts` inside `bootSequence`.
98
+ */
99
+ export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
50
100
  /**
51
101
  * @internal — exported for unit tests to reset module state between
52
102
  * scenarios.
@@ -193,6 +243,17 @@ export interface BootSequenceOptions {
193
243
  * arrives, and `hostContext` is captured from `app.getHostContext()`.
194
244
  */
195
245
  readonly preResolvedMeta?: McpAppAiGguiRenderMeta;
246
+ /**
247
+ * Autostart-layer pre-resolution of the read-plane door (ggui#537):
248
+ * a tool result that arrived before `bootSequence` ran carried the
249
+ * view's LOCATOR (`ui://ggui/render/…`) but no bootstrap material —
250
+ * the read-plane-only posture. The runtime cannot read the resource
251
+ * before the App is connected, so the locator is threaded here and
252
+ * resolved via {@link resolveMetaViaReadDoor} right after the
253
+ * handshake, ahead of the Tier 2 tool-result wait. Ignored when
254
+ * `preResolvedMeta` is set.
255
+ */
256
+ readonly preResolvedLocator?: string;
196
257
  /**
197
258
  * How long to wait for the spec-canonical `ui/notifications/tool-result`
198
259
  * notification (Tier 2 of the resolver chain) before failing with
@@ -409,6 +470,29 @@ export declare function extractMetaFromToolResult(params: unknown): McpAppAiGgui
409
470
  * runtime reads it once at autostart.
410
471
  */
411
472
  export declare function readPendingToolResults(): McpAppAiGguiRenderMeta | null;
473
+ /**
474
+ * Sibling of {@link readPendingToolResults} for the read-plane-only
475
+ * posture (ggui#537): the NEWEST buffered tool result that carries a
476
+ * `ui://ggui/render/…` locator but no bootstrap material. Consulted only
477
+ * when the buffer yielded no inline slice; the autostart threads it to
478
+ * `bootSequence` as `preResolvedLocator`, resolved after the handshake.
479
+ */
480
+ export declare function readPendingToolResultLocator(): string | null;
481
+ /**
482
+ * The read-plane door (ggui#537). A server running the read-plane-only
483
+ * posture publishes only the view's identity on the tool result — the
484
+ * `ui://ggui/render/…` locator — and no bootstrap material; the
485
+ * per-render resource that locator names is the self-contained shell,
486
+ * whose document inlines the very envelope this runtime boots from.
487
+ * Ask the HOST to read it (`app.readServerResource` → `resources/read`,
488
+ * proxied by the host's own MCP client — no fetch from this sandbox, so
489
+ * a host CSP without `connect-src` to the server is not in the way),
490
+ * recover the envelope with the protocol's writer/reader pair
491
+ * ({@link readGguiShellEnvelope}), and validate it exactly as an inline
492
+ * slice would be. `null` on any miss (host cannot proxy reads, resource
493
+ * gone, no envelope) — the caller's other tiers still decide.
494
+ */
495
+ export declare function resolveMetaViaReadDoor(app: App, locator: string): Promise<McpAppAiGguiRenderMeta | null>;
412
496
  /**
413
497
  * Render a submit-action's `data` payload as a short inline string
414
498
  * for embedding in a `ui/message` consent prompt. Goal: human-
package/dist/runtime.js CHANGED
@@ -1,5 +1,34 @@
1
- import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, } from '@ggui-ai/protocol/integrations/mcp-apps';
2
- import { parseMetaFromGlobal, parseMetaFromToolResult, } from './meta-parse.js';
1
+ /**
2
+ * `@ggui-ai/iframe-runtime` iframe runtime entry.
3
+ *
4
+ * This is the file esbuild bundles into `dist/iframe-runtime.js`. The
5
+ * thin-shell HTML loads it via `<script type="module" src=".../iframe-runtime.js">`;
6
+ * on import the side-effects below take over: build a status DOM,
7
+ * postMessage `ui/initialize` to the parent, parse the bootstrap, open
8
+ * the WebSocket, run the version handshake, and mount the render
9
+ * placeholder.
10
+ *
11
+ * Boot sequence:
12
+ * - Boot from the `_meta["ai.ggui/render"]` slice received via
13
+ * `ui/initialize`.
14
+ * - Open the WebSocket, run the subscribe handshake.
15
+ * - Mount the render — either a structural placeholder or, when
16
+ * the renderer hooks are wired, a React mount of `componentCode`.
17
+ *
18
+ * The runtime advertises its build version via a post-`ui/initialize`
19
+ * notification (`ggui:renderer-ready`).
20
+ *
21
+ * Failure surfacing: every parse / handshake failure goes to two
22
+ * places — (1) the in-iframe status line (operator-visible) and
23
+ * (2) a parent-bound `postMessage({type:'ggui:bootstrap-failed', ...})`.
24
+ * The post-message envelope is consumed by the `<McpAppIframe>` host
25
+ * wrapper, which routes it to the host's `onError` callback.
26
+ */
27
+ // FIRST import on purpose — declares zod jitless before any
28
+ // schema-defining dependency initializes (see zod-jitless.ts).
29
+ import './zod-jitless.js';
30
+ import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
3
32
  import { projectHostContext } from '@ggui-ai/protocol';
4
33
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
5
34
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
@@ -131,6 +160,32 @@ export function setCurrentApp(app) {
131
160
  function getCurrentApp() {
132
161
  return currentApp;
133
162
  }
163
+ /**
164
+ * The host's announced color mode, read from the connected App's
165
+ * pre-merged hostContext (`ui/initialize` result, kept current by the
166
+ * App across `host-context-changed`). `undefined` when no App is
167
+ * connected, the host sent no `theme`, or it sent something outside
168
+ * the spec's `'light' | 'dark'` literals (dropped, never guessed —
169
+ * the same tolerant posture as `projectHostContext`).
170
+ *
171
+ * This is the INPUT leg of host-theme adaptation
172
+ * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
173
+ * `hostContext.theme` alone reaches only `data-theme` +
174
+ * `color-scheme` via `applyDocumentTheme` — nothing in the token
175
+ * pipeline keys off it, so a dark host painted the light ladder
176
+ * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
177
+ * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
178
+ * every operator layer (live provider > per-render override > app
179
+ * default > server default) still wins by being stamped; the host
180
+ * fills the gap where no operator had an opinion.
181
+ *
182
+ * @internal — exported for unit tests (`setCurrentApp` injects the
183
+ * App); production caller is `buildOpts` inside `bootSequence`.
184
+ */
185
+ export function hostAnnouncedThemeMode() {
186
+ const theme = currentApp?.getHostContext()?.theme;
187
+ return theme === 'light' || theme === 'dark' ? theme : undefined;
188
+ }
134
189
  /**
135
190
  * @internal — exported for unit tests to reset module state between
136
191
  * scenarios.
@@ -314,7 +369,16 @@ export async function bootSequence(opts) {
314
369
  parsed = inline;
315
370
  }
316
371
  else {
317
- const fromToolResult = await toolResultPromise;
372
+ // Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
373
+ // result named the view but carried no material; now that the
374
+ // App is connected, resolve the locator through the host's
375
+ // `resources/read` proxy. One round trip, ahead of the Tier 2
376
+ // wait; a miss falls through to Tier 2 unchanged (its listener
377
+ // is already armed and resolves locator-only arrivals too).
378
+ const viaDoor = opts.preResolvedLocator !== undefined
379
+ ? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
380
+ : null;
381
+ const fromToolResult = viaDoor ?? (await toolResultPromise);
318
382
  if (fromToolResult !== null) {
319
383
  parsed = { ok: true, meta: fromToolResult };
320
384
  }
@@ -1052,6 +1116,26 @@ export function readPendingToolResults() {
1052
1116
  }
1053
1117
  return null;
1054
1118
  }
1119
+ /**
1120
+ * Sibling of {@link readPendingToolResults} for the read-plane-only
1121
+ * posture (ggui#537): the NEWEST buffered tool result that carries a
1122
+ * `ui://ggui/render/…` locator but no bootstrap material. Consulted only
1123
+ * when the buffer yielded no inline slice; the autostart threads it to
1124
+ * `bootSequence` as `preResolvedLocator`, resolved after the handshake.
1125
+ */
1126
+ export function readPendingToolResultLocator() {
1127
+ if (typeof window === 'undefined')
1128
+ return null;
1129
+ const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
1130
+ if (!Array.isArray(raw) || raw.length === 0)
1131
+ return null;
1132
+ for (let i = raw.length - 1; i >= 0; i--) {
1133
+ const locator = extractLocatorFromToolResult(raw[i]);
1134
+ if (locator !== null)
1135
+ return locator;
1136
+ }
1137
+ return null;
1138
+ }
1055
1139
  /**
1056
1140
  * Listen for a `ui/notifications/tool-result` notification via the
1057
1141
  * App's spec-canonical event surface and resolve to the extracted
@@ -1067,36 +1151,75 @@ export function readPendingToolResults() {
1067
1151
  function awaitToolResultMetaFromApp(app, timeoutMs) {
1068
1152
  return new Promise((resolve) => {
1069
1153
  let settled = false;
1070
- const handler = (params) => {
1154
+ const settle = (value) => {
1071
1155
  if (settled)
1072
1156
  return;
1073
- const meta = extractMetaFromToolResult(params);
1074
- if (meta === null)
1075
- return;
1076
1157
  settled = true;
1077
1158
  app.removeEventListener('toolresult', handler);
1078
1159
  clearTimeout(timer);
1079
- resolve(meta);
1160
+ resolve(value);
1080
1161
  };
1081
- app.addEventListener('toolresult', handler);
1082
- const timer = setTimeout(() => {
1162
+ const handler = (params) => {
1083
1163
  if (settled)
1084
1164
  return;
1085
- settled = true;
1086
- app.removeEventListener('toolresult', handler);
1087
- resolve(null);
1088
- }, timeoutMs);
1165
+ const meta = extractMetaFromToolResult(params);
1166
+ if (meta !== null) {
1167
+ settle(meta);
1168
+ return;
1169
+ }
1170
+ // Read-plane door (ggui#537): identity-only result → resolve the
1171
+ // locator through the host's `resources/read` proxy. Async, and
1172
+ // it never settles `null` — a later result carrying the slice
1173
+ // inline (or the timeout) still decides.
1174
+ const locator = extractLocatorFromToolResult(params);
1175
+ if (locator === null)
1176
+ return;
1177
+ void resolveMetaViaReadDoor(app, locator).then((resolved) => {
1178
+ if (resolved !== null)
1179
+ settle(resolved);
1180
+ });
1181
+ };
1182
+ app.addEventListener('toolresult', handler);
1183
+ const timer = setTimeout(() => settle(null), timeoutMs);
1089
1184
  });
1090
1185
  }
1091
1186
  /**
1092
- * Pre-handshake variant — listens to raw `window.message` for the
1093
- * autostart layer. Used BEFORE the App is constructed, so this can't
1094
- * route through `app.addEventListener`. Pairs with the minimal-shell
1095
- * pattern: the shell buffers any tool-results that arrived BEFORE
1096
- * runtime load (read via {@link readPendingToolResults}); this
1097
- * listener catches the ones that arrive AFTER the bundle parses but
1098
- * BEFORE bootSequence runs.
1099
- */
1187
+ * The read-plane door (ggui#537). A server running the read-plane-only
1188
+ * posture publishes only the view's identity on the tool result — the
1189
+ * `ui://ggui/render/…` locator — and no bootstrap material; the
1190
+ * per-render resource that locator names is the self-contained shell,
1191
+ * whose document inlines the very envelope this runtime boots from.
1192
+ * Ask the HOST to read it (`app.readServerResource` → `resources/read`,
1193
+ * proxied by the host's own MCP client — no fetch from this sandbox, so
1194
+ * a host CSP without `connect-src` to the server is not in the way),
1195
+ * recover the envelope with the protocol's writer/reader pair
1196
+ * ({@link readGguiShellEnvelope}), and validate it exactly as an inline
1197
+ * slice would be. `null` on any miss (host cannot proxy reads, resource
1198
+ * gone, no envelope) — the caller's other tiers still decide.
1199
+ */
1200
+ export async function resolveMetaViaReadDoor(app, locator) {
1201
+ let text;
1202
+ try {
1203
+ const res = await app.readServerResource({ uri: locator });
1204
+ const first = res.contents[0];
1205
+ text = first !== undefined && 'text' in first && typeof first.text === 'string' ? first.text : undefined;
1206
+ }
1207
+ catch (err) {
1208
+ // eslint-disable-next-line no-console -- operator-visible: the door is a boot tier, its miss must be legible
1209
+ console.warn(`[ggui-runtime] read-plane door: host could not read ${locator} — ${err instanceof Error ? err.message : String(err)}`);
1210
+ return null;
1211
+ }
1212
+ if (text === undefined)
1213
+ return null;
1214
+ const envelope = readGguiShellEnvelope(text);
1215
+ if (envelope === undefined)
1216
+ return null;
1217
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1218
+ if (!parsed.ok || parsed.meta === undefined)
1219
+ return null;
1220
+ const validated = validateMeta(parsed.meta);
1221
+ return validated.ok ? validated.meta : null;
1222
+ }
1100
1223
  function awaitToolResultMeta(timeoutMs) {
1101
1224
  if (typeof window === 'undefined')
1102
1225
  return Promise.resolve(null);
@@ -1119,8 +1242,17 @@ function awaitToolResultMeta(timeoutMs) {
1119
1242
  return;
1120
1243
  }
1121
1244
  const meta = extractMetaFromToolResult(m.params);
1122
- if (meta !== null)
1123
- settle(meta);
1245
+ if (meta !== null) {
1246
+ settle({ kind: 'meta', meta });
1247
+ return;
1248
+ }
1249
+ // Identity-only result (ggui#537): settle NOW with the locator so
1250
+ // boot starts immediately and the door runs after the handshake —
1251
+ // waiting the full timeout for a slice this posture never sends
1252
+ // would cost every spec-host view 30 s for nothing.
1253
+ const locator = extractLocatorFromToolResult(m.params);
1254
+ if (locator !== null)
1255
+ settle({ kind: 'locator', locator });
1124
1256
  };
1125
1257
  window.addEventListener('message', onMessage);
1126
1258
  const timer = setTimeout(() => settle(null), timeoutMs);
@@ -2969,7 +3101,7 @@ function readLiveBootstrapShape() {
2969
3101
  * re-await the same postMessage or re-parse the same global), saving
2970
3102
  * up to the 30s postMessage timeout for spec-strict hosts.
2971
3103
  */
2972
- function runBootProduction(preResolvedMeta) {
3104
+ function runBootProduction(preResolvedMeta, preResolvedLocator) {
2973
3105
  const { app, transport } = createDefaultApp();
2974
3106
  void bootProduction({
2975
3107
  doc: document,
@@ -2985,6 +3117,7 @@ function runBootProduction(preResolvedMeta) {
2985
3117
  onObserve: postObservabilityToParent,
2986
3118
  onLifecycle: postLifecycleToParent,
2987
3119
  ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
3120
+ ...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
2988
3121
  });
2989
3122
  }
2990
3123
  if (shouldAutostart() && typeof window !== 'undefined') {
@@ -3015,15 +3148,26 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3015
3148
  }
3016
3149
  else {
3017
3150
  const buffered = readPendingToolResults();
3151
+ const bufferedLocator = buffered === null ? readPendingToolResultLocator() : null;
3018
3152
  if (buffered !== null) {
3019
3153
  runBootProduction(buffered);
3020
3154
  }
3155
+ else if (bufferedLocator !== null) {
3156
+ // Read-plane-only posture (ggui#537): the buffered result named
3157
+ // the view; the door resolves it right after the handshake.
3158
+ runBootProduction(undefined, bufferedLocator);
3159
+ }
3021
3160
  else if (readLiveBootstrapShape()) {
3022
3161
  runBootProduction();
3023
3162
  }
3024
3163
  else {
3025
- void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageMeta) => {
3026
- runBootProduction(postMessageMeta ?? undefined);
3164
+ void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((pre) => {
3165
+ if (pre === null)
3166
+ runBootProduction();
3167
+ else if (pre.kind === 'meta')
3168
+ runBootProduction(pre.meta);
3169
+ else
3170
+ runBootProduction(undefined, pre.locator);
3027
3171
  });
3028
3172
  }
3029
3173
  }
@@ -3302,10 +3446,30 @@ async function bootProduction(opts) {
3302
3446
  ...(meta.gadgets !== undefined
3303
3447
  ? { gadgetPackages: meta.gadgets.map((g) => g.package) }
3304
3448
  : {}),
3305
- ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3306
- ...(meta.themeMode !== undefined
3307
- ? { themeMode: meta.themeMode }
3449
+ // Strict-CSP module variant (ggui#522 slice 2) — attached to
3450
+ // the INLINE SEED mount only, detected by the absence of the
3451
+ // server-assigned ledger fields (`buildGguiSessionSeedInput`
3452
+ // omits them; every wire-delivered GguiSession carries them).
3453
+ // The variant URL names the SEEDED bytes: a WS-delivered
3454
+ // body may differ, and importing a stale variant would paint
3455
+ // the WRONG component rather than fail. A WS redelivery of
3456
+ // the SAME bytes loses nothing — same code ⇒ the renderer
3457
+ // skips re-evaluation entirely.
3458
+ ...(meta.codeModuleUrl !== undefined &&
3459
+ render.type !== 'mcpApps' &&
3460
+ render.type !== 'system' &&
3461
+ !('createdAt' in render)
3462
+ ? { codeModuleUrl: meta.codeModuleUrl }
3308
3463
  : {}),
3464
+ ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3465
+ // Mode: the slice's stamped `themeMode` (every operator layer
3466
+ // resolves into it) wins; when absent, the HOST's announced
3467
+ // theme fills the gap (ggui#551 — see `hostAnnouncedThemeMode`).
3468
+ // Absent-in-slice means "no operator opinion", not "light".
3469
+ ...(() => {
3470
+ const themeMode = meta.themeMode ?? hostAnnouncedThemeMode();
3471
+ return themeMode !== undefined ? { themeMode } : {};
3472
+ })(),
3309
3473
  // Per-app theme overlay (St3 M2.2). Threaded straight from the
3310
3474
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3311
3475
  // already injection-validated by the wire parser) onto the mount
@@ -3493,5 +3657,8 @@ async function bootProduction(opts) {
3493
3657
  ...(opts.preResolvedMeta !== undefined
3494
3658
  ? { preResolvedMeta: opts.preResolvedMeta }
3495
3659
  : {}),
3660
+ ...(opts.preResolvedLocator !== undefined
3661
+ ? { preResolvedLocator: opts.preResolvedLocator }
3662
+ : {}),
3496
3663
  });
3497
3664
  }
package/dist/server.d.ts CHANGED
@@ -34,3 +34,22 @@ export declare const RUNTIME_DIST_DIR: string;
34
34
  * `bootstrap.runtimeUrl`, never hardcodes it.
35
35
  */
36
36
  export declare const RUNTIME_BUNDLE_URL_PATH: "/_ggui/iframe-runtime.js";
37
+ /**
38
+ * Directory of the static shim modules (`dist/shims/<name>.js`) the
39
+ * build emits alongside the bundle (ggui#522 slice 2 — `asset-url`
40
+ * delivery). The mcp-server captures these files at composition and
41
+ * serves them under `/_ggui/shims/<runtime-hash>/<name>.js` with the
42
+ * same immutable posture as the hashed bundle route: the shims ship in
43
+ * the same dist as the bundle, so the bundle's content hash versions
44
+ * both — a shim-affecting change in the design package also changes
45
+ * the bundle bytes.
46
+ */
47
+ export declare const RUNTIME_SHIMS_DIR: string;
48
+ /**
49
+ * The URL prefix under which the mcp-server publishes the shim
50
+ * directory family. The full shim directory for a given build is
51
+ * `<origin>${RUNTIME_SHIMS_URL_PREFIX}/<runtime-hash>` — the server-
52
+ * side `asset-url` rewrite composes shim URLs on it, and the shim
53
+ * route parses it back.
54
+ */
55
+ export declare const RUNTIME_SHIMS_URL_PREFIX: "/_ggui/shims";