@ggui-ai/mcp-server 0.2.0-alpha.4 → 0.3.0-rc.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.
Files changed (152) hide show
  1. package/dist/admin-blueprints-transport.d.ts.map +1 -1
  2. package/dist/admin-blueprints-transport.js +2 -1
  3. package/dist/admin-oauth-providers-transport.d.ts.map +1 -1
  4. package/dist/admin-oauth-providers-transport.js +7 -5
  5. package/dist/api-renders-routes.d.ts +85 -0
  6. package/dist/api-renders-routes.d.ts.map +1 -0
  7. package/dist/api-renders-routes.js +372 -0
  8. package/dist/build-mcp.d.ts +1 -1
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +39 -5
  11. package/dist/code-routes.d.ts +47 -0
  12. package/dist/code-routes.d.ts.map +1 -0
  13. package/dist/code-routes.js +81 -0
  14. package/dist/code-store-fs.js +2 -2
  15. package/dist/console-auth.d.ts +10 -10
  16. package/dist/console-auth.d.ts.map +1 -1
  17. package/dist/console-auth.js +5 -5
  18. package/dist/console-blueprint-routes.d.ts +71 -0
  19. package/dist/console-blueprint-routes.d.ts.map +1 -0
  20. package/dist/console-blueprint-routes.js +348 -0
  21. package/dist/console-chat-routes.d.ts +80 -0
  22. package/dist/console-chat-routes.d.ts.map +1 -0
  23. package/dist/console-chat-routes.js +182 -0
  24. package/dist/console-config-routes.d.ts +37 -0
  25. package/dist/console-config-routes.d.ts.map +1 -0
  26. package/dist/console-config-routes.js +91 -0
  27. package/dist/console-headers.d.ts +1 -1
  28. package/dist/console-info-routes.d.ts +84 -0
  29. package/dist/console-info-routes.d.ts.map +1 -0
  30. package/dist/console-info-routes.js +135 -0
  31. package/dist/console-keys-routes.d.ts +50 -0
  32. package/dist/console-keys-routes.d.ts.map +1 -0
  33. package/dist/console-keys-routes.js +222 -0
  34. package/dist/console-llm-keys-routes.d.ts +47 -0
  35. package/dist/console-llm-keys-routes.d.ts.map +1 -0
  36. package/dist/console-llm-keys-routes.js +443 -0
  37. package/dist/console-mcp-tools-routes.d.ts +41 -0
  38. package/dist/console-mcp-tools-routes.d.ts.map +1 -0
  39. package/dist/console-mcp-tools-routes.js +60 -0
  40. package/dist/console-registry-routes.d.ts +66 -0
  41. package/dist/console-registry-routes.d.ts.map +1 -0
  42. package/dist/console-registry-routes.js +276 -0
  43. package/dist/console-session-routes.d.ts +89 -0
  44. package/dist/console-session-routes.d.ts.map +1 -0
  45. package/dist/console-session-routes.js +385 -0
  46. package/dist/console-sessions-routes.d.ts +52 -0
  47. package/dist/console-sessions-routes.d.ts.map +1 -0
  48. package/dist/console-sessions-routes.js +106 -0
  49. package/dist/console-static-routes.d.ts +54 -0
  50. package/dist/console-static-routes.d.ts.map +1 -0
  51. package/dist/console-static-routes.js +190 -0
  52. package/dist/console-theme-routes.d.ts +3 -3
  53. package/dist/console-theme-routes.js +1 -1
  54. package/dist/console-timeline.d.ts +5 -5
  55. package/dist/console-timeline.d.ts.map +1 -1
  56. package/dist/console-timeline.js +27 -26
  57. package/dist/console-welcome.js +2 -2
  58. package/dist/email-login.d.ts.map +1 -1
  59. package/dist/email-login.js +2 -3
  60. package/dist/ggui-session-channel/action-ingress.d.ts +54 -0
  61. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -0
  62. package/dist/ggui-session-channel/action-ingress.js +228 -0
  63. package/dist/ggui-session-channel/channel-subscriptions.d.ts +97 -0
  64. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -0
  65. package/dist/ggui-session-channel/channel-subscriptions.js +224 -0
  66. package/dist/ggui-session-channel/internal-types.d.ts +102 -0
  67. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -0
  68. package/dist/ggui-session-channel/internal-types.js +6 -0
  69. package/dist/ggui-session-channel/outbound.d.ts +81 -0
  70. package/dist/ggui-session-channel/outbound.d.ts.map +1 -0
  71. package/dist/ggui-session-channel/outbound.js +174 -0
  72. package/dist/ggui-session-channel/socket-router.d.ts +38 -0
  73. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -0
  74. package/dist/ggui-session-channel/socket-router.js +213 -0
  75. package/dist/ggui-session-channel/subscribe.d.ts +165 -0
  76. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -0
  77. package/dist/ggui-session-channel/subscribe.js +370 -0
  78. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +40 -0
  79. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -0
  80. package/dist/ggui-session-channel/subscriber-lifecycle.js +123 -0
  81. package/dist/ggui-session-channel.d.ts +425 -0
  82. package/dist/ggui-session-channel.d.ts.map +1 -0
  83. package/dist/ggui-session-channel.js +262 -0
  84. package/dist/health-routes.d.ts +76 -0
  85. package/dist/health-routes.d.ts.map +1 -0
  86. package/dist/health-routes.js +145 -0
  87. package/dist/index.d.ts +10 -11
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -9
  90. package/dist/instructions-presets.d.ts +3 -3
  91. package/dist/instructions-presets.js +24 -24
  92. package/dist/llm-backed-negotiator.d.ts +68 -67
  93. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  94. package/dist/llm-backed-negotiator.js +82 -221
  95. package/dist/mcp-apps-outbound.d.ts +47 -48
  96. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  97. package/dist/mcp-apps-outbound.js +154 -177
  98. package/dist/mcp-endpoint-routes.d.ts +88 -0
  99. package/dist/mcp-endpoint-routes.d.ts.map +1 -0
  100. package/dist/mcp-endpoint-routes.js +359 -0
  101. package/dist/mcp-mounts.d.ts +2 -76
  102. package/dist/mcp-mounts.d.ts.map +1 -1
  103. package/dist/mcp-mounts.js +0 -76
  104. package/dist/oauth-as-routes.d.ts +60 -0
  105. package/dist/oauth-as-routes.d.ts.map +1 -0
  106. package/dist/oauth-as-routes.js +82 -0
  107. package/dist/oauth-clients-routes.d.ts +39 -0
  108. package/dist/oauth-clients-routes.d.ts.map +1 -0
  109. package/dist/oauth-clients-routes.js +87 -0
  110. package/dist/oauth-login-types.d.ts +1 -20
  111. package/dist/oauth-login-types.d.ts.map +1 -1
  112. package/dist/oauth-login-types.js +30 -7
  113. package/dist/oauth-login.d.ts.map +1 -1
  114. package/dist/oauth-login.js +3 -2
  115. package/dist/oauth-providers-store.d.ts.map +1 -1
  116. package/dist/oauth-providers-store.js +5 -5
  117. package/dist/oauth.d.ts +9 -8
  118. package/dist/oauth.d.ts.map +1 -1
  119. package/dist/oauth.js +41 -19
  120. package/dist/pairing-transport.d.ts.map +1 -1
  121. package/dist/pairing-transport.js +2 -1
  122. package/dist/request-context.d.ts +2 -2
  123. package/dist/request-context.js +2 -2
  124. package/dist/reserved-validators.d.ts.map +1 -1
  125. package/dist/reserved-validators.js +9 -1
  126. package/dist/route-param.d.ts +9 -0
  127. package/dist/route-param.d.ts.map +1 -0
  128. package/dist/route-param.js +10 -0
  129. package/dist/runtime-bundle-route.d.ts +43 -0
  130. package/dist/runtime-bundle-route.d.ts.map +1 -0
  131. package/dist/runtime-bundle-route.js +80 -0
  132. package/dist/schema-compat.d.ts +64 -62
  133. package/dist/schema-compat.d.ts.map +1 -1
  134. package/dist/schema-compat.js +23 -51
  135. package/dist/server.d.ts +179 -193
  136. package/dist/server.d.ts.map +1 -1
  137. package/dist/server.js +644 -3759
  138. package/dist/storage.d.ts +5 -5
  139. package/dist/storage.d.ts.map +1 -1
  140. package/dist/storage.js +5 -5
  141. package/dist/thread-transport.d.ts.map +1 -1
  142. package/dist/thread-transport.js +4 -3
  143. package/dist/user-session-auth.d.ts +7 -21
  144. package/dist/user-session-auth.d.ts.map +1 -1
  145. package/dist/user-session-auth.js +7 -28
  146. package/package.json +16 -15
  147. package/dist/mcp-apps-inbound.d.ts +0 -86
  148. package/dist/mcp-apps-inbound.d.ts.map +0 -1
  149. package/dist/mcp-apps-inbound.js +0 -283
  150. package/dist/render-channel.d.ts +0 -694
  151. package/dist/render-channel.d.ts.map +0 -1
  152. package/dist/render-channel.js +0 -1775
@@ -1,1775 +0,0 @@
1
- /**
2
- * OSS live channel — live session plane over WebSocket.
3
- *
4
- * The live channel is where the typed-channel contract is enforced on
5
- * live traffic between the server and the user. It co-hosts on the
6
- * same Express server as `/mcp` and reuses the same
7
- * `@ggui-ai/mcp-server-handlers/renders` helpers that the
8
- * closed hosted server consumes.
9
- *
10
- * Scope:
11
- *
12
- * - `subscribe` → auth, resolve-or-create session, register subscriber,
13
- * reply `ack` with the session's current stack + sequence.
14
- * - `action` → inbound user action carried as an {@link ActionEnvelope}.
15
- * Gated through `assertEventAllowed` (allowlist) +
16
- * `assertActionContract` (payload, for data:submit). Persisted to
17
- * RenderStore as a typed session event.
18
- * - `ping`/`pong` → heartbeat parity with hosted.
19
- * - `close`/socket-close → clean subscriber teardown.
20
- * - `sendToSession(renderId, data)` → outbound fan-out API for
21
- * mutation handlers (ggui_emit / connector `ctx.send`). Validated
22
- * through `assertStreamContract` before delivery.
23
- *
24
- * `props_update`: mount handlers dispatched through the wired-action
25
- * router can call `ctx.sendPropsUpdate(renderId, props)` to fan a
26
- * `{type:'props_update'}` frame to live subscribers without going
27
- * through a refresh-stream path. Reaches the renderer's existing
28
- * `props_update` branch in `iframe-runtime` and applies new props
29
- * in-place. This seam is scoped to mount tools; the agent-driven
30
- * `ggui_update` path is handled separately.
31
- *
32
- * Not handled here:
33
- *
34
- * - Pattern-B daemon-agent notification stream (GET /mcp SSE).
35
- * - Short-lived session-token mint/consume — that's ggui_render-gated.
36
- * Dev-mode auth (any bearer via existing AuthAdapter) matches the
37
- * `/mcp` endpoint's shape and is operator-replaceable.
38
- */
39
- import { InMemorySessionStreamBuffer, InProcessStreamFanout, NoopTelemetrySink, } from "@ggui-ai/mcp-server-core/in-memory";
40
- import { assertActionContract, assertStreamContract } from "@ggui-ai/mcp-server-handlers/renders";
41
- import { CONTRACT_ERROR_CHANNEL, ContractViolationError, sanitizeCausedBy as defaultSanitizeCausedBy, EMPTY_REFRESH_INPUT, makeContractErrorPayload, PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED, } from "@ggui-ai/protocol";
42
- import { randomUUID } from "node:crypto";
43
- import { WebSocketServer } from "ws";
44
- import { resolveIdentityFromHeaders, UnauthenticatedError } from "./auth.js";
45
- // `assertEventAllowed` + `EventNotAllowedError` were removed from
46
- // `@ggui-ai/mcp-server-handlers/renders` in Phase B alongside
47
- // the session-stack collapse — the event-allowlist concept on a
48
- // `StackItem.subscription` no longer has a wire shape to bind to.
49
- // Local stand-ins keep the inbound-action allowlist call sites
50
- // compiling until the event-allowlist semantics are re-thought in a
51
- // follow-up slice (see "B.2d render-channel inbound-action allowlist
52
- // deferred" in the report).
53
- class EventNotAllowedError extends Error {
54
- constructor(message) {
55
- super(message);
56
- this.name = "EventNotAllowedError";
57
- }
58
- }
59
- function assertEventAllowed(_subscription, _type) {
60
- // No-op: pre-Phase-B this read `StackItem.subscription` and rejected
61
- // event types not on the allowlist. Post-collapse there's no
62
- // subscription field on `Render`; the gate is deferred to a follow-up
63
- // slice that defines per-render event policy on the new wire shape.
64
- }
65
- /** Default URL path for the channel endpoint. Operators can override. */
66
- export const DEFAULT_RENDER_CHANNEL_PATH = "/ws";
67
- /**
68
- * Default + boundary cadence for the channel-subscribe polling loop.
69
- * Server-authoritative: clients propose `pollIntervalMs` on
70
- * `channel_subscribe`, server clamps to [floorMs, ceilingMs] and
71
- * defaults to `defaultMs` when absent. Conservative defaults — operators
72
- * tune via {@link RenderChannelLocalToolsOptions.pollCadence}.
73
- */
74
- const DEFAULT_CHANNEL_POLL_FLOOR_MS = 1_000;
75
- const DEFAULT_CHANNEL_POLL_CEILING_MS = 60_000;
76
- const DEFAULT_CHANNEL_POLL_DEFAULT_MS = 10_000;
77
- /**
78
- * Default timeout for a single wired-tool invocation, in ms. Operators
79
- * override via {@link RenderChannelOptions.wiredActionTimeoutMs}; the
80
- * 30 s ceiling is a honest non-promise: long-running tools MUST design
81
- * their own completion path (streaming, polling).
82
- */
83
- export const DEFAULT_WIRED_TOOL_TIMEOUT_MS = 30_000;
84
- /**
85
- * Raised by `invokeWithTimeout` when a wired-tool call exceeds its
86
- * per-call budget. Internal — surfaces as a `TOOL_TIMEOUT` code on
87
- * {@link ContractErrorPayload} before the caller sees any channel
88
- * output.
89
- */
90
- class WiredToolTimeoutError extends Error {
91
- toolName;
92
- timeoutMs;
93
- constructor(toolName, timeoutMs) {
94
- super(`Wired tool '${toolName}' did not complete within ${timeoutMs}ms`);
95
- this.name = "WiredToolTimeoutError";
96
- this.toolName = toolName;
97
- this.timeoutMs = timeoutMs;
98
- }
99
- }
100
- /**
101
- * Build an OSS live-channel server. The returned object is designed to be
102
- * composed into `createGguiServer` — see `server.ts` for the wire-up.
103
- */
104
- export function createRenderChannelServer(opts) {
105
- const path = opts.path ?? DEFAULT_RENDER_CHANNEL_PATH;
106
- // Outbound stream buffer — owns seq assignment + bounded replay
107
- // storage. Default is in-memory; operators swap via `opts.streamBuffer`.
108
- const streamBuffer = opts.streamBuffer ?? new InMemorySessionStreamBuffer();
109
- // Live-tail pub/sub. Default in-process; hosted binds RedisPubSubFanout.
110
- const streamFanout = opts.streamFanout ?? new InProcessStreamFanout();
111
- // `causedBy` sanitizer applied to every contract-error emission.
112
- // Defaults to the protocol's pattern-based redactor (Bearer tokens,
113
- // query-param secrets, env-var dumps, 2 KB truncation). Operators
114
- // pass their own to tighten or broaden coverage.
115
- const sanitize = opts.sanitizeCausedBy ?? defaultSanitizeCausedBy;
116
- // Operational telemetry — default no-op. Fires `wired-tool.invoked`
117
- // on every successful wired-action dispatch (C12); future sites
118
- // (refresh-stream success / failure counts) reuse the same sink.
119
- const telemetry = opts.telemetry ?? new NoopTelemetrySink();
120
- // Channel-subscribe local-tool poll plumbing. Resolved once
121
- // at composition so the `channel_subscribe` handler doesn't pay the
122
- // option-spread cost per request. Absent ⇒ all channel subscribes
123
- // reject with `CHANNEL_NOT_LOCAL`.
124
- const localTools = opts.streamWebSocketLocalTools;
125
- const localToolsAllowlist = localTools
126
- ? new Set(localTools.allowlist)
127
- : new Set();
128
- const pollFloorMs = localTools?.pollCadence?.floorMs ?? DEFAULT_CHANNEL_POLL_FLOOR_MS;
129
- const pollCeilingMs = localTools?.pollCadence?.ceilingMs ?? DEFAULT_CHANNEL_POLL_CEILING_MS;
130
- const pollDefaultMs = localTools?.pollCadence?.defaultMs ?? DEFAULT_CHANNEL_POLL_DEFAULT_MS;
131
- // `noServer: true` means we own the upgrade wiring (see handleUpgrade);
132
- // ws won't try to bind its own port.
133
- const wss = new WebSocketServer({ noServer: true });
134
- /**
135
- * Flat set of all live WS subscribers. Replaces the per-session
136
- * `subscribersBySession` Map — routing is now StreamFanout's job;
137
- * this set tracks WS-specific bookkeeping (stats, shutdown-broadcast)
138
- * that the seam can't see (and shouldn't).
139
- */
140
- const wsSubscribers = new Set();
141
- /** ws → subscriber reverse index so socket-close can look up cheaply. */
142
- const subscribersByWs = new WeakMap();
143
- /**
144
- * Per-session local subscriber count. Drives the {@link
145
- * RenderChannelOptions.onFirstSubscriber} / `onLastSubscriberGone`
146
- * 0↔1 transition hooks used by cloud adapters for per-session
147
- * cross-pod pub/sub channel scoping. Distinct from the
148
- * `sessionCount` getter — that walks `wsSubscribers` on demand;
149
- * this map is the registration-time counter the hooks key off.
150
- */
151
- const sessionCountById = new Map();
152
- /**
153
- * Pump live frames from the StreamFanout iterator out to this
154
- * subscriber's WS. Started fire-and-forget by `register`; ends when
155
- * the iterator yields done (close() on the seam) OR `unregister`
156
- * calls `iter.return()`. Per-subscriber seq filter applied here:
157
- * frames with `seq <= replayCompletedSeq` were (or will be)
158
- * delivered via the replay path on subscribe.
159
- *
160
- * The pump's first action is `await iter.next()`, which yields
161
- * control back to the event loop. This is what preserves the
162
- * subscribe-handler ordering invariant: ack → replay frames →
163
- * live frames. The replay-frame send loop completes synchronously
164
- * before the pump can ever send anything, regardless of fanout
165
- * timing.
166
- */
167
- async function pumpSubscriber(sub) {
168
- try {
169
- for (;;) {
170
- const { value, done } = await sub.iter.next();
171
- if (done)
172
- return;
173
- if (value.seq <= sub.replayCompletedSeq)
174
- continue;
175
- if (sub.ws.readyState !== sub.ws.OPEN) {
176
- await sub.iter.return?.();
177
- return;
178
- }
179
- send(sub.ws, { type: "data", payload: value });
180
- }
181
- }
182
- catch (err) {
183
- opts.logger.warn("session_channel_pump_failed", {
184
- renderId: sub.renderId,
185
- error: String(err),
186
- });
187
- }
188
- }
189
- function register(sub) {
190
- wsSubscribers.add(sub);
191
- subscribersByWs.set(sub.ws, sub);
192
- // Per-session count bookkeeping + 0→1 hook for cloud pubsub
193
- // adapter scoping. Increment FIRST so the hook sees the up-to-date
194
- // state; hook fires only on the transition (prevCount === 0).
195
- const prevCount = sessionCountById.get(sub.renderId) ?? 0;
196
- sessionCountById.set(sub.renderId, prevCount + 1);
197
- if (prevCount === 0 && opts.onFirstSubscriber) {
198
- try {
199
- opts.onFirstSubscriber(sub.renderId);
200
- }
201
- catch (err) {
202
- // Best-effort: a thrown hook MUST NOT corrupt the
203
- // wsSubscribers set vs the real socket lifecycle.
204
- opts.logger.warn("session_channel_on_first_subscriber_threw", {
205
- renderId: sub.renderId,
206
- error: String(err),
207
- });
208
- }
209
- }
210
- // Start the pump loop. Fire-and-forget — pump errors are logged
211
- // inside pumpSubscriber, never propagated.
212
- void pumpSubscriber(sub);
213
- }
214
- function unregister(ws) {
215
- const sub = subscribersByWs.get(ws);
216
- if (!sub)
217
- return;
218
- subscribersByWs.delete(ws);
219
- wsSubscribers.delete(sub);
220
- // Per-session count bookkeeping + 1→0 hook (symmetric with register).
221
- const prevCount = sessionCountById.get(sub.renderId) ?? 0;
222
- if (prevCount <= 1) {
223
- sessionCountById.delete(sub.renderId);
224
- if (prevCount === 1 && opts.onLastSubscriberGone) {
225
- try {
226
- opts.onLastSubscriberGone(sub.renderId);
227
- }
228
- catch (err) {
229
- opts.logger.warn("session_channel_on_last_subscriber_gone_threw", {
230
- renderId: sub.renderId,
231
- error: String(err),
232
- });
233
- }
234
- }
235
- }
236
- else {
237
- sessionCountById.set(sub.renderId, prevCount - 1);
238
- }
239
- // Ending the iter terminates pumpSubscriber AND unregisters this
240
- // subscriber from the StreamFanout. Idempotent on the seam side
241
- // (close-after-return is a no-op).
242
- void sub.iter.return?.();
243
- // Tear down every `channel_subscribe` polling loop owned
244
- // by this subscriber. Symmetric with stream-iterator teardown
245
- // above. clearInterval is idempotent on already-cleared handles,
246
- // so a concurrent channel_unsubscribe + WS close is safe.
247
- for (const state of sub.channelSubs.values()) {
248
- clearInterval(state.timer);
249
- }
250
- sub.channelSubs.clear();
251
- }
252
- function send(ws, msg) {
253
- if (ws.readyState !== ws.OPEN)
254
- return;
255
- try {
256
- ws.send(JSON.stringify(msg));
257
- }
258
- catch (err) {
259
- opts.logger.warn("session_channel_send_failed", { error: String(err) });
260
- }
261
- }
262
- function sendError(ws, code, message, requestId, details) {
263
- send(ws, {
264
- type: "error",
265
- payload: { code, message, ...(details !== undefined ? { details } : {}) },
266
- ...(requestId ? { requestId } : {}),
267
- });
268
- }
269
- /**
270
- * Shared tenancy guard for client-emitted observation messages
271
- * (`host_context_observed`, `canvas_navigated`). Returns `false`
272
- * AND emits the appropriate error frame when:
273
- *
274
- * - the socket has no bound subscriber (NOT_SUBSCRIBED)
275
- * - payload.renderId doesn't match the subscriber binding
276
- * (SESSION_MISMATCH)
277
- *
278
- * Subscriber binding is the authoritative tenancy scope. The wire
279
- * payload's renderId is belt-and-suspenders so the error message
280
- * can be specific; appId narrows transparently via the binding.
281
- */
282
- function checkSubscriberTenancy(ws, sub, payload, messageType, requestId) {
283
- if (!sub) {
284
- sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${messageType}'`, requestId);
285
- return false;
286
- }
287
- if (payload.renderId !== sub.renderId) {
288
- sendError(ws, "SESSION_MISMATCH", `${messageType} payload id '${payload.renderId ?? "<missing>"}' does not match subscriber render '${sub.renderId}'`, requestId);
289
- return false;
290
- }
291
- return true;
292
- }
293
- /**
294
- * Persist an observation-message-driven session patch. Fire-and-
295
- * forget at the wire layer (no response frame); warn-logs persistence
296
- * errors so transient store failures stay observable without
297
- * disrupting the iframe. The iframe's local state is already in the
298
- * new shape; the next round-trip re-emits whatever the persistence
299
- * layer lost.
300
- */
301
- async function applySessionPatch(renderId, appId, messageType, patch) {
302
- try {
303
- await opts.renderStore.update(renderId, patch);
304
- }
305
- catch (err) {
306
- opts.logger.warn("session_channel_observation_persist_failed", {
307
- messageType,
308
- renderId,
309
- appId,
310
- error: err instanceof Error ? err.message : String(err),
311
- });
312
- }
313
- }
314
- /**
315
- * Emit a `channel_error` frame to a specific subscriber. Used by the
316
- * `channel_subscribe` handler for both subscribe-time rejections
317
- * (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `RENDER_NOT_FOUND`,
318
- * `SUBSCRIBE_UNAUTHORIZED`) AND poll-time failures (`POLL_FAILED`).
319
- *
320
- * Direct-to-WS, not via fanOut — channel_error frames are
321
- * per-subscriber and not stored in the replay buffer. A new
322
- * subscriber on the same render will re-subscribe and discover the
323
- * same error itself.
324
- */
325
- function sendChannelError(ws, renderId, channelName, code, message, requestId, details) {
326
- send(ws, {
327
- type: "channel_error",
328
- payload: {
329
- renderId,
330
- channelName,
331
- code,
332
- message,
333
- ...(details !== undefined ? { details } : {}),
334
- },
335
- ...(requestId ? { requestId } : {}),
336
- });
337
- }
338
- /**
339
- * Clamp a client-supplied `pollIntervalMs` against the configured
340
- * floor / ceiling. Absent (or non-finite) ⇒ default. Server is
341
- * authoritative per the `ChannelSubscribePayload.pollIntervalMs`
342
- * docstring — clients propose, server clamps.
343
- */
344
- function clampPollInterval(supplied) {
345
- if (typeof supplied !== "number" || !Number.isFinite(supplied)) {
346
- return pollDefaultMs;
347
- }
348
- if (supplied < pollFloorMs)
349
- return pollFloorMs;
350
- if (supplied > pollCeilingMs)
351
- return pollCeilingMs;
352
- return supplied;
353
- }
354
- /**
355
- * Run one poll of `state.toolName` and fan its result onto the
356
- * subscriber's WS as a `channel_payload`. Throws never escape — a
357
- * thrown invocation surfaces as a `channel_error{code:'POLL_FAILED'}`
358
- * but the polling loop keeps running so transient tool failures are
359
- * recoverable.
360
- *
361
- * Tool registry is guaranteed-present by the caller — channel
362
- * subscribes whose `source.tool` isn't in `localTools.allowlist`
363
- * never reach this function.
364
- */
365
- async function pollChannelOnce(sub, state) {
366
- if (!localTools)
367
- return;
368
- if (sub.ws.readyState !== sub.ws.OPEN)
369
- return;
370
- try {
371
- const output = await localTools.invoke(state.toolName, state.args);
372
- // Skip emission if the socket closed during the poll — closing-
373
- // raced timers fire at most once, and emitting onto a closing
374
- // socket is a `send_failed` warning at best.
375
- if (sub.ws.readyState !== sub.ws.OPEN)
376
- return;
377
- state.seq += 1;
378
- send(sub.ws, {
379
- type: "channel_payload",
380
- payload: {
381
- renderId: sub.renderId,
382
- appId: sub.appId,
383
- channelName: state.channelName,
384
- seq: state.seq,
385
- ts: new Date().toISOString(),
386
- // Default mode for source-fed channels is `replace` — each
387
- // poll is a fresh snapshot, not a delta. Channels that need
388
- // append semantics declare `mode: 'append'` on streamSpec;
389
- // honoring that is the iframe-runtime's concern at fold
390
- // time. See `ChannelPayloadFrame.mode`.
391
- mode: "replace",
392
- payload: output,
393
- },
394
- });
395
- }
396
- catch (err) {
397
- if (sub.ws.readyState !== sub.ws.OPEN)
398
- return;
399
- sendChannelError(sub.ws, sub.renderId, state.channelName, "POLL_FAILED", err instanceof Error ? err.message : String(err), undefined,
400
- // causedBy slot — sanitized for credential safety in the same
401
- // posture as wired-tool's TOOL_THREW emission.
402
- sanitize(err instanceof Error ? (err.stack ?? err.message) : String(err)));
403
- opts.logger.warn("session_channel_channel_poll_failed", {
404
- renderId: sub.renderId,
405
- appId: sub.appId,
406
- channelName: state.channelName,
407
- toolName: state.toolName,
408
- error: String(err),
409
- });
410
- }
411
- }
412
- /**
413
- * Handle a `channel_subscribe` message. Validates the request,
414
- * resolves the channel's `source.tool` against the configured
415
- * allowlist, and schedules a polling loop. Idempotent on
416
- * `${renderId}:${channelName}` — a re-subscribe replaces any
417
- * existing interval rather than running two in parallel.
418
- */
419
- async function handleChannelSubscribe(ws, sub, message) {
420
- const payload = message.payload;
421
- // renderId match — the spoof guard at every wire-input boundary.
422
- // A subscriber bound to session A can't drive a subscribe for
423
- // session B even if they crafted the inbound payload.
424
- if (payload.renderId !== sub.renderId) {
425
- sendChannelError(ws, payload.renderId, payload.channelName, "SUBSCRIBE_UNAUTHORIZED", `Subscriber is bound to session '${sub.renderId}' but channel_subscribe targets '${payload.renderId}'`, message.requestId);
426
- return;
427
- }
428
- // Without an `streamWebSocketLocalTools` allowlist, no channel
429
- // can be subscribed locally. The iframe must fall back to direct
430
- // polling via the MCP host proxy.
431
- if (!localTools) {
432
- sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_NOT_LOCAL", "This server has no streamWebSocketLocalTools allowlist; iframe must poll the source tool directly.", message.requestId);
433
- return;
434
- }
435
- // Phase B: a render IS the addressable unit. The prior
436
- // reverse-index lookup collapses — `renderStore.get` resolves
437
- // the render directly.
438
- const stored = await opts.renderStore.get(payload.renderId);
439
- if (!stored || stored.id !== sub.renderId) {
440
- sendChannelError(ws, payload.renderId, payload.channelName, "RENDER_NOT_FOUND", `Render '${payload.renderId}' not found on subscriber '${sub.renderId}'`, message.requestId);
441
- return;
442
- }
443
- const render = stored.render;
444
- // Channel entry resolution. mcpApps / system variants have no
445
- // streamSpec so the field reads back as undefined — same code
446
- // path as a component variant without the channel declared.
447
- const streamSpec = render.type === "mcpApps" || render.type === "system"
448
- ? undefined
449
- : render.streamSpec;
450
- const channelEntry = streamSpec?.[payload.channelName];
451
- if (!channelEntry || !channelEntry.source) {
452
- sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_UNKNOWN", `streamSpec['${payload.channelName}'] not declared OR has no source.tool on render '${payload.renderId}'`, message.requestId);
453
- return;
454
- }
455
- const sourceTool = channelEntry.source.tool;
456
- if (!localToolsAllowlist.has(sourceTool)) {
457
- sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_NOT_LOCAL", `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
458
- return;
459
- }
460
- // Validation passed — schedule (or re-schedule) the polling loop.
461
- const channelKey = `${payload.renderId}:${payload.channelName}`;
462
- // Idempotent replace: a reconnect that re-subscribes the same
463
- // (render, channel) pair gets a fresh timer + zeroed seq. The
464
- // client's gap-detection treats it as a new stream from the
465
- // server's perspective; client-side reconnect logic owns
466
- // continuity if the channel was declared `mode: 'append'`.
467
- const existing = sub.channelSubs.get(channelKey);
468
- if (existing) {
469
- clearInterval(existing.timer);
470
- sub.channelSubs.delete(channelKey);
471
- }
472
- const pollIntervalMs = clampPollInterval(payload.pollIntervalMs);
473
- // Layered args: source.args defines defaults; client args override
474
- // per ChannelSubscribePayload.args docstring.
475
- const mergedArgs = {
476
- ...(channelEntry.source.args ?? {}),
477
- ...(payload.args ?? {}),
478
- };
479
- // Resolve the channelKey lookup at callback fire-time. The
480
- // timer callback reads `sub.channelSubs.get(channelKey)` so
481
- // `state` doesn't have to be referenced through a closure
482
- // before it's actually inserted into the tracker. Also self-
483
- // cleans on the zombie-timer path: if the subscription was
484
- // already torn down (channel_unsubscribe / WS close / replace),
485
- // the lookup returns undefined and we clearInterval ourselves.
486
- const timer = setInterval(() => {
487
- const live = sub.channelSubs.get(channelKey);
488
- if (!live || !subscribersByWs.has(sub.ws)) {
489
- clearInterval(timer);
490
- return;
491
- }
492
- void pollChannelOnce(sub, live);
493
- }, pollIntervalMs);
494
- const state = {
495
- pollIntervalMs,
496
- toolName: sourceTool,
497
- renderId: payload.renderId,
498
- channelName: payload.channelName,
499
- args: mergedArgs,
500
- seq: 0,
501
- timer,
502
- };
503
- sub.channelSubs.set(channelKey, state);
504
- opts.logger.info("render_channel_channel_subscribe", {
505
- renderId: sub.renderId,
506
- appId: sub.appId,
507
- channelName: payload.channelName,
508
- toolName: sourceTool,
509
- pollIntervalMs,
510
- });
511
- // Eager-poll — fire one invocation immediately so the iframe sees
512
- // an initial value without waiting `pollIntervalMs`. Matches the
513
- // user-expected "subscribe then see data" cadence; the interval
514
- // takes over from there.
515
- void pollChannelOnce(sub, state);
516
- }
517
- /**
518
- * Handle a `channel_unsubscribe` message. Idempotent: a no-op
519
- * unsubscribe on an unknown channelKey returns silently. WS close
520
- * implicitly unsubscribes every channel; this message is for
521
- * mid-session cancellation.
522
- */
523
- function handleChannelUnsubscribe(_ws, sub, message) {
524
- const payload = message.payload;
525
- if (payload.renderId !== sub.renderId) {
526
- // No-op silently — the canonical "spoof guard" code path is in
527
- // channel_subscribe; unsubscribe gets no error frame to avoid
528
- // leaking cross-session existence.
529
- return;
530
- }
531
- const channelKey = `${payload.renderId}:${payload.channelName}`;
532
- const existing = sub.channelSubs.get(channelKey);
533
- if (!existing)
534
- return;
535
- clearInterval(existing.timer);
536
- sub.channelSubs.delete(channelKey);
537
- opts.logger.info("render_channel_channel_unsubscribe", {
538
- renderId: sub.renderId,
539
- appId: sub.appId,
540
- channelName: payload.channelName,
541
- });
542
- }
543
- /**
544
- * Stamp a delivery through the replay buffer and fan it out to every
545
- * subscriber of the session, honoring the per-subscriber replay
546
- * cursor. Shared by the public `sendToSession` entry point AND by
547
- * the wiredActionRouter's refresh/error emissions — extracting this
548
- * avoids duplicating the seq-stamp + subscriber-iteration logic in
549
- * two places.
550
- *
551
- * Caller is responsible for validating `delivery.payload` against
552
- * the active streamSpec BEFORE calling — the fan-out here trusts
553
- * its input. Reserved-channel emissions (e.g. `_ggui:contract-
554
- * error`) bypass the streamSpec check upstream via
555
- * `assertStreamContract`.
556
- */
557
- async function fanOut(delivery, activeStreamSpec) {
558
- const { envelope } = await streamBuffer.record(delivery, activeStreamSpec);
559
- // Publish to the seam — InProcessStreamFanout walks its subscriber
560
- // queues synchronously inside publish(), so no real async hop. The
561
- // pump loop on each WS subscriber yields the envelope, applies the
562
- // per-sub replay-cursor filter, and sends to the WS. Fire-and-forget
563
- // because publish() never throws on the in-process impl, and a hosted
564
- // RedisPubSubFanout failure here would already be persisted to the
565
- // SessionStreamBuffer for replay-recovery on reconnect.
566
- void streamFanout.publish({ renderId: envelope.renderId, envelope });
567
- return { seq: envelope.seq };
568
- }
569
- /**
570
- * Emit a canonical {@link ContractErrorPayload} on the reserved
571
- * `_ggui:contract-error` channel. Never throws — the wiredActionRouter
572
- * dispatch path calls this from `catch` branches; a second failure
573
- * would be a footgun.
574
- */
575
- function emitContractError(renderId, activeStreamSpec, payload) {
576
- try {
577
- // Central stamp — re-emit through makeContractErrorPayload so
578
- // every contract-error envelope carries the current stamped
579
- // schemaVersion regardless of which dispatch branch produced
580
- // `payload`. Byte-equivalent to the pre-Item-1 inline stamp
581
- // (`{...payload, schemaVersion: PROTOCOL_SCHEMA_VERSION}`) which
582
- // also always clobbered to the current version — the local
583
- // dispatch paths never forward pre-stamped payloads, so any
584
- // incoming schemaVersion on `payload` is discarded by design.
585
- const stamped = makeContractErrorPayload({
586
- toolName: payload.toolName,
587
- error: payload.error,
588
- timestamp: payload.timestamp,
589
- ...(payload.actionName !== undefined ? { actionName: payload.actionName } : {}),
590
- ...(payload.sourceAction !== undefined ? { sourceAction: payload.sourceAction } : {}),
591
- });
592
- // Fire-and-forget: emitContractError is called from sync `catch`
593
- // branches and a second async failure here would be a footgun.
594
- // Promise rejection logs but doesn't propagate; the seam contract
595
- // says publish never throws on InProcess impl, and a hosted
596
- // RedisPubSubFanout failure is recoverable via replay-on-reconnect.
597
- void fanOut({
598
- renderId,
599
- channel: CONTRACT_ERROR_CHANNEL,
600
- mode: "append",
601
- payload: stamped,
602
- }, activeStreamSpec).catch((err) => {
603
- opts.logger.error("session_channel_contract_error_emit_failed", {
604
- renderId,
605
- toolName: payload.toolName,
606
- code: payload.error.code,
607
- error: String(err),
608
- });
609
- });
610
- }
611
- catch (err) {
612
- opts.logger.error("session_channel_contract_error_emit_failed", {
613
- renderId,
614
- toolName: payload.toolName,
615
- code: payload.error.code,
616
- error: String(err),
617
- });
618
- }
619
- }
620
- /**
621
- * Race a wired-tool invocation against a timeout. On timeout, the
622
- * underlying promise is abandoned (we do NOT cancel the tool —
623
- * handlers are trusted to clean up their own resources) but the
624
- * caller sees a {@link WiredToolTimeoutError} and emits
625
- * `TOOL_TIMEOUT`.
626
- */
627
- async function invokeWithTimeout(router, toolName,
628
- // Accepts either a validated wired-action payload (Record) OR the
629
- // typed empty refresh input ({@link RefreshInput} /
630
- // {@link EMPTY_REFRESH_INPUT}). The two call sites upstream are
631
- // the only producers; nothing else should widen this.
632
- input, ctx, timeoutMs) {
633
- let timer;
634
- try {
635
- return await Promise.race([
636
- router.invoke(toolName, input, ctx),
637
- new Promise((_, reject) => {
638
- timer = setTimeout(() => {
639
- reject(new WiredToolTimeoutError(toolName, timeoutMs));
640
- }, timeoutMs);
641
- }),
642
- ]);
643
- }
644
- finally {
645
- if (timer)
646
- clearTimeout(timer);
647
- }
648
- }
649
- /**
650
- * Internal impl behind the public {@link RenderChannelServer.sendPropsUpdate}.
651
- * Extracted as a closure-level function so the wired-action dispatcher
652
- * can build a `WiredActionContext.sendPropsUpdate` that closes over the
653
- * same logic without forward-referencing the returned object. Best-
654
- * effort + orphan-tolerant per the docstring on the public method.
655
- */
656
- async function sendPropsUpdateImpl(renderId, props) {
657
- let stored;
658
- try {
659
- stored = await opts.renderStore.get(renderId);
660
- }
661
- catch (err) {
662
- opts.logger.warn("render_channel_props_update_lookup_failed", {
663
- renderId,
664
- error: String(err),
665
- });
666
- return;
667
- }
668
- if (!stored) {
669
- opts.logger.warn("render_channel_props_update_orphan", {
670
- renderId,
671
- });
672
- return;
673
- }
674
- // Filter the flat WS-subscriber set by renderId; same posture as
675
- // `notifyRenderPush`. `send()` already silently skips closed sockets
676
- // and logs (but doesn't throw on) per-subscriber send failures, so
677
- // the caller's mount-handler path can't be made to fail by a dead
678
- // WebSocket.
679
- for (const sub of wsSubscribers) {
680
- if (sub.renderId !== renderId)
681
- continue;
682
- send(sub.ws, {
683
- type: "props_update",
684
- payload: { renderId, props },
685
- });
686
- }
687
- }
688
- /**
689
- * Dispatch a wired action after inbound validation has passed.
690
- * Called synchronously inside `handleInboundAction` — the caller
691
- * awaits so the UI's ack arrives AFTER any refresh-stream frames
692
- * land (honest ordering: "when you see the ack, your action
693
- * completed + the screen reflects it").
694
- *
695
- * No-ops when:
696
- * - no wiredActionRouter is configured on this channel
697
- * - the action didn't resolve to a tool name (plain agent-routed
698
- * action that we persist + forward as-is)
699
- * - the resolved tool isn't registered (emits `TOOL_NOT_FOUND`)
700
- *
701
- * On successful tool invocation, every declared channel on the
702
- * active stack item's streamSpec with a `tool` refresh hint fires
703
- * that tool + emits its return value on the channel. Refresh tools
704
- * are invoked with an empty argument object (`{}`); authors who
705
- * need filter args should inline the action + refresh into a single
706
- * tool that returns the new state.
707
- */
708
- async function dispatchWiredAction(stored, activeItem, envelope, dispatchedAt) {
709
- // Post-Phase-B, the active "session" is just the stored render's
710
- // id. Keep a `session` alias inside the body so the existing
711
- // `session.id` references stay readable without a wholesale
712
- // re-name pass.
713
- const session = stored;
714
- const router = opts.wiredActionRouter;
715
- if (!router || !activeItem || envelope.type !== "data:submit")
716
- return;
717
- const payload = envelope.payload;
718
- if (!payload || typeof payload.action !== "string")
719
- return;
720
- // Disagreement policy — the server-side enforcement point for the
721
- // `client wins` rule documented on `ActionEventValue.tool`. Prefer
722
- // the envelope's client-populated `tool` (useAction fills it from
723
- // the same contract lookup the server would redo). Fall back to the
724
- // actionSpec declaration so a client that omits the wire hint still
725
- // gets the expected routing. If the two disagree, client wins — the
726
- // client is the source of truth for what the user actually saw.
727
- // Cross-validation against the agent's tracked contract happens on
728
- // the agent-SDK side, not here.
729
- // actionSpec / streamSpec only exist on ComponentRender. The
730
- // mcpApps / system variants narrow them to undefined.
731
- const componentItem = activeItem.type === "mcpApps" || activeItem.type === "system" ? undefined : activeItem;
732
- const actionEntry = componentItem?.actionSpec?.[payload.action];
733
- const serverDeclaredTool = actionEntry?.nextStep;
734
- const declaredTool = (typeof payload.tool === "string" && payload.tool.length > 0 ? payload.tool : undefined) ??
735
- serverDeclaredTool;
736
- if (!declaredTool)
737
- return;
738
- const actionName = payload.action;
739
- const input = payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)
740
- ? payload.data
741
- : {};
742
- const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
743
- const streamSpec = componentItem?.streamSpec;
744
- if (!router.has(declaredTool)) {
745
- emitContractError(session.id, streamSpec, {
746
- toolName: declaredTool,
747
- actionName,
748
- sourceAction: { type: "wired-action", dispatchedAt },
749
- error: {
750
- code: "TOOL_NOT_FOUND",
751
- message: `wiredActionRouter has no handler for tool '${declaredTool}'`,
752
- },
753
- timestamp: new Date().toISOString(),
754
- });
755
- opts.logger.warn("session_channel_wired_tool_not_found", {
756
- renderId: session.id,
757
- toolName: declaredTool,
758
- actionName,
759
- });
760
- return;
761
- }
762
- // Build the wired-action context the router hands the mount tool.
763
- // `sendPropsUpdate` closes over the active
764
- // `session.id` so a buggy mount can't accidentally cross-deliver to
765
- // another session by passing a foreign renderId. The same ctx is
766
- // reused for the refresh-stream pass below — a refresh tool that
767
- // wants to fire props_update can do so, though the canonical site
768
- // is the action tool itself.
769
- const wiredCtx = {
770
- renderId: session.id,
771
- sendPropsUpdate(props) {
772
- void sendPropsUpdateImpl(session.id, props);
773
- },
774
- };
775
- const invokeStartedAt = Date.now();
776
- try {
777
- await invokeWithTimeout(router, declaredTool, input, wiredCtx, timeoutMs);
778
- }
779
- catch (err) {
780
- const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
781
- emitContractError(session.id, streamSpec, {
782
- toolName: declaredTool,
783
- actionName,
784
- sourceAction: { type: "wired-action", dispatchedAt },
785
- error: {
786
- code,
787
- message: err instanceof Error ? err.message : String(err),
788
- ...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
789
- },
790
- timestamp: new Date().toISOString(),
791
- });
792
- opts.logger.warn("session_channel_wired_tool_failed", {
793
- renderId: session.id,
794
- toolName: declaredTool,
795
- actionName,
796
- code,
797
- error: String(err),
798
- });
799
- return;
800
- }
801
- // Operational telemetry — record the successful dispatch. Lossy
802
- // by contract (TelemetrySink.emit is sync + non-throwing); a bad
803
- // sink MUST NOT block the refresh pass. Attribute set is kept
804
- // flat + primitive-only per TelemetryEvent.attributes shape.
805
- telemetry.emit({
806
- name: "wired-tool.invoked",
807
- at: Date.now(),
808
- attributes: {
809
- toolName: declaredTool,
810
- actionName,
811
- renderId: session.id,
812
- latencyMs: Date.now() - invokeStartedAt,
813
- },
814
- });
815
- // Refresh pass — every declared channel with a `tool` hint fires a
816
- // fresh read and emits the result on that channel. Each refresh
817
- // tool gets its own timeout + isolation: one broken refresh MUST
818
- // NOT block others from completing.
819
- if (!streamSpec)
820
- return;
821
- for (const [channelName, channelEntry] of Object.entries(streamSpec)) {
822
- const refreshTool = channelEntry?.tool;
823
- if (!refreshTool)
824
- continue;
825
- if (!router.has(refreshTool)) {
826
- emitContractError(session.id, streamSpec, {
827
- toolName: refreshTool,
828
- actionName,
829
- sourceAction: { type: "refresh-stream", dispatchedAt },
830
- error: {
831
- code: "TOOL_NOT_FOUND",
832
- message: `wiredActionRouter has no handler for refresh tool '${refreshTool}' (channel '${channelName}')`,
833
- },
834
- timestamp: new Date().toISOString(),
835
- });
836
- continue;
837
- }
838
- let output;
839
- try {
840
- // Refresh input is v1-locked to the empty shape via
841
- // EMPTY_REFRESH_INPUT. DO NOT replace with an inline `{}`
842
- // literal — the named constant is what keeps this contract
843
- // grep-able and future-proofs v2 evolution (see
844
- // {@link RefreshInput}).
845
- output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
846
- }
847
- catch (err) {
848
- const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
849
- emitContractError(session.id, streamSpec, {
850
- toolName: refreshTool,
851
- actionName,
852
- sourceAction: { type: "refresh-stream", dispatchedAt },
853
- error: {
854
- code,
855
- message: err instanceof Error ? err.message : String(err),
856
- ...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
857
- },
858
- timestamp: new Date().toISOString(),
859
- });
860
- opts.logger.warn("session_channel_refresh_tool_failed", {
861
- renderId: session.id,
862
- toolName: refreshTool,
863
- channel: channelName,
864
- code,
865
- error: String(err),
866
- });
867
- continue;
868
- }
869
- try {
870
- assertStreamContract(streamSpec, channelName, output, opts.extraReservedValidators);
871
- }
872
- catch (err) {
873
- if (err instanceof ContractViolationError) {
874
- emitContractError(session.id, streamSpec, {
875
- toolName: refreshTool,
876
- actionName,
877
- sourceAction: { type: "refresh-stream", dispatchedAt },
878
- error: {
879
- code: "SCHEMA_VIOLATION",
880
- message: err.message,
881
- },
882
- timestamp: new Date().toISOString(),
883
- });
884
- opts.logger.warn("session_channel_refresh_schema_violation", {
885
- renderId: session.id,
886
- toolName: refreshTool,
887
- channel: channelName,
888
- violations: err.violations,
889
- });
890
- continue;
891
- }
892
- throw err;
893
- }
894
- try {
895
- await fanOut({
896
- renderId: session.id,
897
- channel: channelName,
898
- mode: channelEntry?.mode ?? "append",
899
- payload: output,
900
- }, streamSpec);
901
- }
902
- catch (err) {
903
- // fanOut swallows per-subscriber transport errors; a throw here
904
- // is buffer-internal (e.g., record() invariant violation). Log
905
- // but don't propagate — a single broken channel must not take
906
- // down the session.
907
- opts.logger.error("session_channel_refresh_emit_failed", {
908
- renderId: session.id,
909
- toolName: refreshTool,
910
- channel: channelName,
911
- error: String(err),
912
- });
913
- }
914
- }
915
- }
916
- async function resolveIdentityFromUpgrade(req) {
917
- const url = new URL(req.url ?? "/", "http://localhost");
918
- // WS-token gate: when `?wsToken=` is present AND the channel is
919
- // configured with ws-token-auth plumbing, skip the AuthAdapter
920
- // entirely at upgrade. The real identity is established in
921
- // handleSubscribe when the subscribe payload's `wsToken` is
922
- // verified. This is how MCP Apps iframes connect — they can't set
923
- // Authorization headers, and the token model is subscribe-scoped
924
- // anyway.
925
- //
926
- // URL-gate here is NOT verification — it's a "don't reject the
927
- // upgrade for missing bearer" signal. The real verify runs at
928
- // subscribe time; an invalid token reaches that point and is
929
- // rejected with BOOTSTRAP_INVALID.
930
- if (opts.bootstrap && url.searchParams.has("wsToken")) {
931
- return {
932
- identity: {
933
- kind: "user",
934
- userId: "__bootstrap_pending__",
935
- workspaceId: "__bootstrap_pending__",
936
- roles: [],
937
- },
938
- source: "apikey",
939
- };
940
- }
941
- // Embedded-ui cookie gate. Consulted ONLY when the channel is
942
- // configured with cookie-auth plumbing AND no bootstrap is in
943
- // play. Unlike bootstrap, cookies ARE verified here: the single
944
- // consumer (console SPA) sets the cookie out-of-band via
945
- // `POST /ggui/console/render-cookie` and we do want to
946
- // reject the upgrade cleanly (HTTP 401 → browser WS error) when
947
- // the cookie is stale/missing, not carry a doomed handshake into
948
- // subscribe where the error surface is worse.
949
- //
950
- // On success, we stash the bound `{renderId, appId}` on the
951
- // request so `handleSubscribe` can enforce that the subscribe
952
- // payload targets exactly those values. No synthesis from the
953
- // AuthAdapter — cookies ARE the auth signal.
954
- if (opts.cookieAuth) {
955
- const raw = opts.cookieAuth.readCookie(req.headers);
956
- if (raw) {
957
- const bound = opts.cookieAuth.verify(raw);
958
- if (bound) {
959
- req.__gguiCookieBound = bound;
960
- return {
961
- identity: { kind: "builder" },
962
- source: "apikey",
963
- };
964
- }
965
- // Cookie present but invalid — do NOT fall through to the
966
- // bearer path. An invalid cookie is a same-origin user error,
967
- // not a pass-through condition.
968
- throw new UnauthenticatedError("console cookie invalid");
969
- }
970
- // No cookie present → fall through to bearer path below. Mixed
971
- // deployments (pairing-token bearer + same-origin cookie for
972
- // viewer) are legal.
973
- }
974
- // Browsers can't set Authorization on native WebSocket. Fall back
975
- // to `?token=<jwt>` for web clients, matching the convention most
976
- // session-channel endpoints ship with. Server-side clients (Node
977
- // `ws`, tests) continue to set the header directly.
978
- if (!req.headers["authorization"]) {
979
- const token = url.searchParams.get("token");
980
- if (token) {
981
- req.headers["authorization"] = `Bearer ${token}`;
982
- }
983
- }
984
- return resolveIdentityFromHeaders(opts.auth, req.headers, req.socket?.remoteAddress ?? undefined);
985
- }
986
- /**
987
- * Resolve the active render variant for contract enforcement. Phase
988
- * B collapsed the prior (stack, currentStackIndex) lookup — a render
989
- * IS the addressable unit, so the active render is the stored render
990
- * itself. MCP Apps / system variants narrow to `undefined` so
991
- * upstream enforcement skips (allowlist + actionSpec checks are
992
- * no-ops when no `ComponentRender` is active).
993
- */
994
- function resolveActiveRender(render) {
995
- if (!render)
996
- return undefined;
997
- if (render.type === "mcpApps" || render.type === "system")
998
- return undefined;
999
- return render;
1000
- }
1001
- /**
1002
- * Handle an inbound `action` message — the canonical flat
1003
- * {@link ActionEnvelope} shape.
1004
- *
1005
- * Two-step enforcement: (1) allowlist via {@link assertEventAllowed}
1006
- * against the active stack item's `subscription.events`; (2)
1007
- * actionSpec payload check via {@link assertActionContract} for
1008
- * `data:submit` types. Both helpers are shared with the hosted
1009
- * `handle-action.ts` ingress.
1010
- */
1011
- async function handleInboundAction(ws, sub, message) {
1012
- const envelope = message.payload;
1013
- // Spoof guard — envelope.renderId is REQUIRED on the wire and
1014
- // MUST match the subscriber's bound session.
1015
- if (envelope.renderId !== sub.renderId) {
1016
- sendError(ws, "SESSION_MISMATCH", `Action targets session '${envelope.renderId}' but this socket is subscribed to '${sub.renderId}'`, message.requestId);
1017
- return;
1018
- }
1019
- const stored = await opts.renderStore.get(sub.renderId);
1020
- if (!stored) {
1021
- sendError(ws, "RENDER_NOT_FOUND", `Render ${sub.renderId} no longer exists`, message.requestId);
1022
- return;
1023
- }
1024
- // Phase B: a render IS the addressable unit. The prior stack
1025
- // routing (stackIndex / cross-stack pickIds) collapses — the
1026
- // resolved render itself is the active item.
1027
- const activeItem = resolveActiveRender(stored.render);
1028
- // ── Two-step enforcement ──
1029
- // 1. allowlist via assertEventAllowed (Phase B: no-op stub —
1030
- // Render no longer carries a `subscription` allowlist;
1031
- // reinstating per-render event policy is deferred.)
1032
- // 2. actionSpec payload check via assertActionContract (data:submit)
1033
- // Envelope.payload for data:submit carries the ActionEventValue
1034
- // shape (`{action, data?, tool?}`).
1035
- try {
1036
- assertEventAllowed(undefined, envelope.type);
1037
- }
1038
- catch (err) {
1039
- if (err instanceof EventNotAllowedError) {
1040
- opts.logger.warn("render_channel_event_not_allowed", {
1041
- renderId: sub.renderId,
1042
- envelope: "action",
1043
- error: err.message,
1044
- });
1045
- sendError(ws, "EVENT_NOT_ALLOWED", err.message, message.requestId);
1046
- return;
1047
- }
1048
- throw err;
1049
- }
1050
- if (envelope.type === "data:submit") {
1051
- try {
1052
- const activeActionSpec = activeItem && activeItem.type !== "mcpApps" && activeItem.type !== "system"
1053
- ? activeItem.actionSpec
1054
- : undefined;
1055
- assertActionContract(activeActionSpec, envelope.payload);
1056
- }
1057
- catch (err) {
1058
- if (err instanceof ContractViolationError) {
1059
- opts.logger.warn("session_channel_contract_violation", {
1060
- renderId: sub.renderId,
1061
- violations: err.violations,
1062
- envelope: "action",
1063
- });
1064
- sendError(ws, "CONTRACT_VIOLATION", err.message, message.requestId, err.toErrorData());
1065
- return;
1066
- }
1067
- throw err;
1068
- }
1069
- }
1070
- // Persist the envelope. RenderStore.appendEvent assigns a monotonic
1071
- // seq the client acks back with so reconnects can resume via `fromSeq`.
1072
- const dispatchedAt = new Date().toISOString();
1073
- let seq;
1074
- try {
1075
- seq = await opts.renderStore.appendEvent({
1076
- renderId: sub.renderId,
1077
- type: "user.submitted",
1078
- data: envelope,
1079
- });
1080
- }
1081
- catch (err) {
1082
- opts.logger.error("session_channel_append_failed", {
1083
- renderId: sub.renderId,
1084
- error: String(err),
1085
- });
1086
- sendError(ws, "APPEND_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
1087
- return;
1088
- }
1089
- // wiredActionRouter — fire any declared action-tool +
1090
- // stream-refresh tools BEFORE acking the user. Honest ordering:
1091
- // when the ack lands, any refresh frames the tool produced have
1092
- // already fanned out, so the client can treat "ack received" as
1093
- // "UI state reflects my action". No-op when no router is
1094
- // configured OR when the action didn't declare a tool.
1095
- //
1096
- // Awaited (not fire-and-forget): dispatchWiredAction internally
1097
- // catches every failure and emits contract-error envelopes; a
1098
- // throw out of this call would be a platform bug and we'd rather
1099
- // see it in tests than silently drop.
1100
- await dispatchWiredAction(stored, activeItem, envelope, dispatchedAt);
1101
- // StreamFanout pump-drain: dispatchWiredAction's internal fanOut
1102
- // calls `streamFanout.publish()` which queues envelopes into the
1103
- // pump's async iterator. The pump's `await iter.next()` resolves
1104
- // on the microtask queue — without this drain, the synchronous
1105
- // `send(ws, ack)` below races ahead of the pump's `send(ws, data)`,
1106
- // and the data-before-ack invariant fails. `setImmediate` waits for
1107
- // the next macrotask, which is strictly after all pending
1108
- // microtasks (the pump iterations) drain. OSS-only invariant —
1109
- // hosted Path A's RedisPubSubFanout can't preserve cross-pod
1110
- // ordering by construction; clients on hosted treat data + ack as
1111
- // independent signals.
1112
- await new Promise((resolve) => setImmediate(resolve));
1113
- send(ws, {
1114
- type: "ack",
1115
- payload: { sequence: seq, timestamp: Date.now() },
1116
- ...(message.requestId ? { requestId: message.requestId } : {}),
1117
- });
1118
- }
1119
- async function handleSubscribe(ws, identity, message) {
1120
- const payload = message.payload;
1121
- // Protocol-version handshake. Opt-in on the client side: absent
1122
- // `supportedVersions` is legacy-pass-through. When present,
1123
- // require the server's PROTOCOL_SCHEMA_VERSION to be in the
1124
- // declared set — otherwise emit UPGRADE_REQUIRED.
1125
- //
1126
- // - 'reject' (default): emit + close the connection. Canonical
1127
- // posture for first-party servers.
1128
- // - 'advisory' (opt-out): emit + keep the connection
1129
- // open (but stop the subscribe; no ack, no session work).
1130
- // Clients that ignore the code continue exactly as
1131
- // pre-handshake.
1132
- //
1133
- // Placed FIRST — before bootstrap verify (which consumes the
1134
- // single-use bootstrap token per the RenderChannelBootstrap
1135
- // docstring) and before session lookup/creation (DB work).
1136
- // Bootstrap iframes with a version mismatch must retry with a
1137
- // fresh bootstrap token; burning the token on a mismatch the
1138
- // client could detect by reading the version-negotiation spec
1139
- // would be a footgun.
1140
- if (Array.isArray(payload.supportedVersions) &&
1141
- payload.supportedVersions.length > 0 &&
1142
- !payload.supportedVersions.includes(PROTOCOL_SCHEMA_VERSION)) {
1143
- const policy = opts.versionPolicy ?? "reject";
1144
- sendError(ws, UPGRADE_REQUIRED, `Server speaks ${PROTOCOL_SCHEMA_VERSION}; client declared ` +
1145
- `supportedVersions=[${payload.supportedVersions.join(", ")}].`, message.requestId, {
1146
- serverVersion: PROTOCOL_SCHEMA_VERSION,
1147
- clientSupportedVersions: payload.supportedVersions,
1148
- policy,
1149
- });
1150
- opts.logger.warn("session_channel_version_mismatch", {
1151
- renderId: payload.renderId,
1152
- appId: payload.appId,
1153
- serverVersion: PROTOCOL_SCHEMA_VERSION,
1154
- clientSupportedVersions: payload.supportedVersions,
1155
- policy,
1156
- });
1157
- if (policy === "reject") {
1158
- try {
1159
- ws.close();
1160
- }
1161
- catch {
1162
- // best-effort — socket may already be closing
1163
- }
1164
- }
1165
- return;
1166
- }
1167
- // WS-token-auth path. When `payload.wsToken` is present, the MCP
1168
- // Apps iframe is asking us to authenticate it via the short-lived
1169
- // token minted by `ggui_render`. This REPLACES the upgrade-time
1170
- // AuthAdapter identity — iframes don't carry bearer tokens.
1171
- // Mutually-exclusive on purpose.
1172
- let effectiveIdentity = identity;
1173
- let mintedSessionToken;
1174
- if (typeof payload.wsToken === "string" && payload.wsToken.length > 0) {
1175
- if (!opts.bootstrap) {
1176
- sendError(ws, "BOOTSTRAP_NOT_SUPPORTED", "This server was not configured with ws-token-auth plumbing", message.requestId);
1177
- return;
1178
- }
1179
- const verifyResult = opts.bootstrap.verify(payload.wsToken);
1180
- if (!verifyResult.ok) {
1181
- opts.logger.warn("session_channel_bootstrap_rejected", {
1182
- renderId: payload.renderId,
1183
- appId: payload.appId,
1184
- reason: verifyResult.reason,
1185
- });
1186
- // G14 (2026-05-23): distinguish `expired` from `invalid` so the
1187
- // iframe-side handler can branch on refresh-vs-rehandshake.
1188
- // Tamper / format / kind failures collapse into BOOTSTRAP_INVALID
1189
- // (no refresh path); expired-but-signed envelopes emit the
1190
- // dedicated BOOTSTRAP_EXPIRED so the client knows to call
1191
- // `ggui_runtime_refresh_bootstrap`.
1192
- if (verifyResult.reason === "expired") {
1193
- sendError(ws, "BOOTSTRAP_EXPIRED", "Bootstrap token expired — call ggui_runtime_refresh_bootstrap or re-handshake", message.requestId);
1194
- }
1195
- else {
1196
- sendError(ws, "BOOTSTRAP_INVALID", "Bootstrap token invalid (bad signature, malformed, or wrong kind)", message.requestId);
1197
- }
1198
- return;
1199
- }
1200
- const bound = { renderId: verifyResult.renderId, appId: verifyResult.appId };
1201
- if (bound.renderId !== payload.renderId) {
1202
- sendError(ws, "BOOTSTRAP_SESSION_MISMATCH", `Bootstrap token is bound to session '${bound.renderId}' but subscribe targets '${payload.renderId}'`, message.requestId);
1203
- return;
1204
- }
1205
- if (bound.appId !== payload.appId) {
1206
- sendError(ws, "BOOTSTRAP_APP_MISMATCH", `Bootstrap token is bound to app '${bound.appId}' but subscribe targets '${payload.appId}'`, message.requestId);
1207
- return;
1208
- }
1209
- // Synthesize a minimal AuthResult from the bootstrap claims.
1210
- // The subscriber row needs an identity for logging and roster
1211
- // inspection; the bootstrap-derived identity is a first-class
1212
- // citizen for the lifetime of this subscription.
1213
- effectiveIdentity = {
1214
- identity: {
1215
- kind: "user",
1216
- userId: bound.renderId,
1217
- workspaceId: bound.appId,
1218
- roles: [],
1219
- },
1220
- source: "apikey",
1221
- };
1222
- // Mint the reconnect credential now — before create/observe
1223
- // work — so a downstream failure doesn't leave the client with
1224
- // no way to resume.
1225
- mintedSessionToken = opts.bootstrap.issueSessionToken(bound.renderId, bound.appId);
1226
- opts.logger.info("session_channel_bootstrap_accepted", {
1227
- renderId: bound.renderId,
1228
- appId: bound.appId,
1229
- });
1230
- }
1231
- // Dev-mode session provisioning: look up first; if not present,
1232
- // create with the client-provided id via the widened
1233
- // CreateSessionInput.id seam. Matches the hosted model's shape
1234
- // (agent creates via ggui_render → client subscribes) in a single
1235
- // step — production deployments tighten this by supplying an
1236
- // AuthAdapter that mints session-scoped tokens on render.
1237
- let session = await opts.renderStore.get(payload.renderId);
1238
- if (session) {
1239
- if (session.appId !== payload.appId) {
1240
- sendError(ws, "APP_MISMATCH", `Session ${payload.renderId} belongs to a different app`, message.requestId);
1241
- return;
1242
- }
1243
- }
1244
- else {
1245
- try {
1246
- session = await opts.renderStore.create({
1247
- id: payload.renderId,
1248
- appId: payload.appId,
1249
- });
1250
- }
1251
- catch (err) {
1252
- sendError(ws, "SESSION_CREATE_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
1253
- return;
1254
- }
1255
- }
1256
- // Snapshot the outbound-stream cursor BEFORE registering the
1257
- // subscriber. Any concurrent producer that calls sendToSession
1258
- // between here and registration gets seq > snapshotSeq, so the
1259
- // subscriber will receive it via live fan-out (not via replay).
1260
- //
1261
- // This is race-safe in single-threaded JS: the next few lines run
1262
- // synchronously up to `register(sub)`, and fan-out's per-subscriber
1263
- // `seq <= replayCompletedSeq` guard takes care of the window.
1264
- // Local alias for the resolved row (handleSubscribe's `session`
1265
- // local IS a `StoredRender` post-collapse; rename kept narrow to
1266
- // avoid a wholesale identifier sweep in this slice).
1267
- const stored = session;
1268
- const snapshotSeq = await streamBuffer.currentSeq(stored.id);
1269
- // Phase B: a render IS the addressable unit, so the active item
1270
- // is the resolved render's visible-bits surface itself.
1271
- const activeItem = stored.render;
1272
- // Reconnect: `fromSeq` present → replay per policy on declared
1273
- // AND reserved channels. Fresh subscribe: `fromSeq` absent →
1274
- // call with `fromSeq=0` + NO spec, so the buffer's spec-channel
1275
- // walk contributes nothing (preserving the "initial state comes
1276
- // from ack.render.props; stream channels are for updates after"
1277
- // doctrine for agent-declared channels) but the reserved-channel
1278
- // walk still surfaces server-pushed state that landed before the
1279
- // subscriber attached.
1280
- const activeStreamSpec = activeItem.type !== "mcpApps" && activeItem.type !== "system"
1281
- ? activeItem.streamSpec
1282
- : undefined;
1283
- const replay = payload.fromSeq !== undefined
1284
- ? await streamBuffer.replay(stored.id, payload.fromSeq, activeStreamSpec)
1285
- : await streamBuffer.replay(stored.id, 0, undefined);
1286
- // Subscribe to the StreamFanout BEFORE constructing the Subscriber:
1287
- // the seam returns an AsyncIterable whose iterator we hand off; the
1288
- // pump loop in `register` consumes it. Eager registration on the
1289
- // seam side means any concurrent `streamFanout.publish` from this
1290
- // point onward queues into our iterator — paired with the
1291
- // replayCompletedSeq cursor below, that's race-free.
1292
- const fanoutIter = streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
1293
- const sub = {
1294
- ws,
1295
- renderId: stored.id,
1296
- appId: stored.appId,
1297
- identity: effectiveIdentity,
1298
- connectedAt: Date.now(),
1299
- replayCompletedSeq: snapshotSeq,
1300
- iter: fanoutIter,
1301
- // Per-subscriber channel-subscribe tracker. Populated
1302
- // lazily by the `channel_subscribe` handler when the operator
1303
- // wired `streamWebSocketLocalTools`; stays empty otherwise.
1304
- channelSubs: new Map(),
1305
- };
1306
- register(sub);
1307
- opts.logger.info("render_channel_subscribed", {
1308
- renderId: stored.id,
1309
- appId: stored.appId,
1310
- identityKind: effectiveIdentity.identity.kind,
1311
- fromSeq: payload.fromSeq,
1312
- snapshotSeq,
1313
- replayCount: replay?.envelopes.length ?? 0,
1314
- replayTruncated: replay?.truncated ?? false,
1315
- bootstrap: mintedSessionToken !== undefined,
1316
- });
1317
- const ackPayload = {
1318
- sequence: stored.eventSequence,
1319
- timestamp: Date.now(),
1320
- render: stored.render,
1321
- streamSeq: snapshotSeq,
1322
- // Advertise the server's protocol version on every successful
1323
- // subscribe ack (SPEC §11.2.2). Clients whose
1324
- // CLIENT_SUPPORTED_VERSIONS doesn't contain this string surface
1325
- // UpgradeRequiredError to their caller; clients that don't wire
1326
- // the handshake ignore the field (legacy-pass-through).
1327
- serverVersion: PROTOCOL_SCHEMA_VERSION,
1328
- ...(replay?.truncated ? { replayTruncated: true } : {}),
1329
- ...(mintedSessionToken !== undefined ? { renderToken: mintedSessionToken } : {}),
1330
- };
1331
- send(ws, {
1332
- type: "ack",
1333
- payload: ackPayload,
1334
- ...(message.requestId ? { requestId: message.requestId } : {}),
1335
- });
1336
- // R7 — RenderEvent ledger replay. When `payload.sinceSequence` is
1337
- // present, fetch events with `seq > sinceSequence` from the per-
1338
- // render ledger and emit each as a `render_event` wire frame
1339
- // BEFORE the per-channel stream-buffer replay. Consumers dispatch
1340
- // by `event.type` to fold the wire-frame-equivalent handler
1341
- // (push/props_update/etc.) — same cursor model as the HTTP
1342
- // `/api/renders/:id/events?sinceSequence=N` endpoint.
1343
- //
1344
- // Horizon gate: a cursor below the server's replay horizon OR
1345
- // above `lastSequence` (stale from a different deployment) emits
1346
- // an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
1347
- // the replay. Client recovery: re-mount from a fresh /state read.
1348
- if (payload.sinceSequence !== undefined) {
1349
- const sinceSeq = payload.sinceSequence;
1350
- if (sinceSeq < 0 || !Number.isInteger(sinceSeq)) {
1351
- sendError(ws, "INVALID_SINCE_SEQUENCE", "sinceSequence must be a non-negative integer", message.requestId);
1352
- }
1353
- else {
1354
- const ledger = await opts.renderStore.listEventsSince(session.id, sinceSeq,
1355
- // Server-side cap matches the HTTP route's default (100).
1356
- // Stress + replay-from-zero workloads cap here.
1357
- 100);
1358
- if (ledger === null) {
1359
- // Session disappeared between resolve and ledger read —
1360
- // already handled by the broader error envelope path; nothing
1361
- // to do here.
1362
- }
1363
- else if (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq) {
1364
- sendError(ws, "REPLAY_HORIZON_PASSED", `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`, message.requestId, { currentSequence: ledger.lastSequence });
1365
- }
1366
- else {
1367
- for (const event of ledger.events) {
1368
- // RenderEvent is now the wire-shape ledger primitive
1369
- // (Wave 7 of flatten-render-identity, 2026-05-28); no
1370
- // projection — emit the store's row directly.
1371
- send(ws, {
1372
- type: "render_event",
1373
- payload: event,
1374
- });
1375
- }
1376
- }
1377
- }
1378
- }
1379
- // Send replay frames AFTER the ack. Ordering by `seq` ASC — the
1380
- // buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
1381
- // up to N replay `data` frames → live tail (seq > N). No explicit
1382
- // "replay end" marker is needed; the client uses envelope.seq as
1383
- // the single source of truth for ordering.
1384
- if (replay) {
1385
- for (const env of replay.envelopes) {
1386
- send(ws, { type: "data", payload: env });
1387
- }
1388
- }
1389
- }
1390
- async function onMessage(ws, raw) {
1391
- const sub = subscribersByWs.get(ws);
1392
- let message;
1393
- try {
1394
- message = JSON.parse(raw);
1395
- }
1396
- catch {
1397
- sendError(ws, "INVALID_JSON", "Message is not valid JSON");
1398
- return;
1399
- }
1400
- if (!message || typeof message !== "object" || typeof message.type !== "string") {
1401
- sendError(ws, "INVALID_MESSAGE", "Message is missing a `type` discriminator");
1402
- return;
1403
- }
1404
- switch (message.type) {
1405
- case "subscribe": {
1406
- // `subscribe` is the only message allowed before identity is
1407
- // bound to a session. Identity was already resolved at upgrade
1408
- // time; we just need to register the subscriber.
1409
- const identity = pendingIdentity.get(ws);
1410
- if (!identity) {
1411
- sendError(ws, "UNAUTHENTICATED", "No identity bound to this socket", message.requestId);
1412
- return;
1413
- }
1414
- // Cookie-scope enforcement: when the upgrade was authenticated
1415
- // via an console cookie, the subscribe payload MUST target
1416
- // the session the cookie was issued for. A valid cookie for
1417
- // session A can't be used to open session B.
1418
- const cookieBound = pendingCookieBinding.get(ws);
1419
- if (cookieBound) {
1420
- if (message.payload.renderId !== cookieBound.renderId) {
1421
- sendError(ws, "DEVTOOL_COOKIE_SESSION_MISMATCH", `Embedded-ui cookie is bound to session '${cookieBound.renderId}' but subscribe targets '${message.payload.renderId}'`, message.requestId);
1422
- return;
1423
- }
1424
- if (message.payload.appId !== cookieBound.appId) {
1425
- sendError(ws, "DEVTOOL_COOKIE_APP_MISMATCH", `Embedded-ui cookie is bound to app '${cookieBound.appId}' but subscribe targets '${message.payload.appId}'`, message.requestId);
1426
- return;
1427
- }
1428
- }
1429
- await handleSubscribe(ws, identity, message);
1430
- pendingIdentity.delete(ws);
1431
- pendingCookieBinding.delete(ws);
1432
- return;
1433
- }
1434
- case "ping":
1435
- send(ws, {
1436
- type: "pong",
1437
- payload: {},
1438
- ...(message.requestId ? { requestId: message.requestId } : {}),
1439
- });
1440
- return;
1441
- case "close":
1442
- // Explicit close from client — unregister + close the socket.
1443
- if (sub)
1444
- unregister(ws);
1445
- ws.close(1000, "client_close");
1446
- return;
1447
- case "action":
1448
- if (!sub) {
1449
- sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'action'", message.requestId);
1450
- return;
1451
- }
1452
- await handleInboundAction(ws, sub, message);
1453
- return;
1454
- case "channel_subscribe":
1455
- if (!sub) {
1456
- sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'channel_subscribe'", message.requestId);
1457
- return;
1458
- }
1459
- await handleChannelSubscribe(ws, sub, message);
1460
- return;
1461
- case "channel_unsubscribe":
1462
- if (!sub) {
1463
- // No subscriber → nothing was subscribed → no-op silently.
1464
- // Returning an error would leak "is this socket subscribed"
1465
- // state for unauthenticated clients.
1466
- return;
1467
- }
1468
- handleChannelUnsubscribe(ws, sub, message);
1469
- return;
1470
- case "host_context_observed":
1471
- // The iframe-runtime echoes its captured `McpUiHostContext`
1472
- // after `ui/initialize` resolves and on every
1473
- // `ui/notifications/host-context-changed` notification. Persist
1474
- // on `Session.hostContext` so `ggui_handshake` and
1475
- // `ggui_consume` can surface it to the agent on subsequent
1476
- // turns. Fire-and-forget on the client side; no response.
1477
- if (!checkSubscriberTenancy(ws, sub, message.payload, message.type, message.requestId)) {
1478
- return;
1479
- }
1480
- await applySessionPatch(sub.renderId, sub.appId, message.type, {
1481
- hostContext: message.payload.hostContext,
1482
- lastActivityAt: Date.now(),
1483
- });
1484
- return;
1485
- case "feedback":
1486
- // Require an active subscription for operational messages.
1487
- if (!sub) {
1488
- sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${message.type}'`, message.requestId);
1489
- return;
1490
- }
1491
- // These OSS channel handlers land incrementally once the
1492
- // matching shared handlers exist in @ggui-ai/mcp-server-handlers.
1493
- // For now the ingress point is documented but rejected with a
1494
- // clear code so clients don't assume silent success.
1495
- sendError(ws, "NOT_IMPLEMENTED", `'${message.type}' not yet handled on the OSS channel server`, message.requestId);
1496
- return;
1497
- default:
1498
- sendError(ws, "UNSUPPORTED_MESSAGE", `Unsupported message type: ${String(message.type)}`, message.requestId);
1499
- }
1500
- }
1501
- /**
1502
- * During the pre-subscribe window, a ws has a resolved identity but
1503
- * no session-bound subscriber yet. We hold the identity here until
1504
- * the first `subscribe` lands; once it does, the subscriber record
1505
- * owns the identity and this entry is cleared.
1506
- */
1507
- const pendingIdentity = new WeakMap();
1508
- /**
1509
- * Embedded-ui cookie binding established at upgrade. When present,
1510
- * `handleSubscribe` enforces `subscribe.renderId === bound.renderId`
1511
- * so a valid cookie can't be used to open a session it wasn't
1512
- * issued for. Parallel to {@link pendingIdentity} — same lifetime,
1513
- * same WeakMap rationale.
1514
- */
1515
- const pendingCookieBinding = new WeakMap();
1516
- wss.on("connection", (ws, req) => {
1517
- // Bind the resolved identity from the upgrade phase. It was
1518
- // attached to the request object in handleUpgrade.
1519
- const identity = req.__gguiIdentity;
1520
- if (identity)
1521
- pendingIdentity.set(ws, identity);
1522
- // Likewise for any cookie binding.
1523
- const cookieBound = req.__gguiCookieBound;
1524
- if (cookieBound)
1525
- pendingCookieBinding.set(ws, cookieBound);
1526
- ws.on("message", (raw) => {
1527
- // `ws.on('message')` delivers Buffer/ArrayBuffer/Buffer[] depending
1528
- // on frame type; normalize to string.
1529
- const text = typeof raw === "string" ? raw : raw.toString("utf8");
1530
- onMessage(ws, text).catch((err) => {
1531
- opts.logger.error("session_channel_message_failed", {
1532
- error: String(err),
1533
- });
1534
- });
1535
- });
1536
- ws.on("close", () => {
1537
- unregister(ws);
1538
- pendingIdentity.delete(ws);
1539
- });
1540
- ws.on("error", (err) => {
1541
- opts.logger.warn("session_channel_socket_error", { error: String(err) });
1542
- });
1543
- });
1544
- return {
1545
- path,
1546
- handleUpgrade(req, socket, head) {
1547
- resolveIdentityFromUpgrade(req)
1548
- .then((identity) => {
1549
- // Stash identity on the request so the 'connection' handler
1550
- // can wire it onto the socket. This is the standard ws
1551
- // per-request piggyback pattern.
1552
- req.__gguiIdentity = identity;
1553
- wss.handleUpgrade(req, socket, head, (ws) => {
1554
- // Expose `upgradeReq` for the connection handler.
1555
- wss.emit("connection", ws, req);
1556
- });
1557
- })
1558
- .catch((err) => {
1559
- if (err instanceof UnauthenticatedError) {
1560
- opts.logger.warn("session_channel_auth_failed", {
1561
- reason: err.message,
1562
- });
1563
- socket.write("HTTP/1.1 401 Unauthorized\r\n" +
1564
- "Connection: close\r\n" +
1565
- "Content-Type: text/plain\r\n\r\n" +
1566
- "Unauthorized: " +
1567
- err.message +
1568
- "\r\n");
1569
- }
1570
- else {
1571
- opts.logger.error("session_channel_upgrade_failed", {
1572
- error: String(err),
1573
- });
1574
- socket.write("HTTP/1.1 500 Internal Server Error\r\n" + "Connection: close\r\n\r\n");
1575
- }
1576
- socket.destroy();
1577
- });
1578
- },
1579
- async sendToSession(delivery) {
1580
- // Outbound fan-out enforcement (defense-in-depth parity with
1581
- // hosted `handle-data.ts`). Re-validates the delivery's payload
1582
- // against the render's streamSpec BEFORE delivery — so a future
1583
- // OSS mutation handler that bypasses the emit-side check can't
1584
- // fan out malformed data to subscribers. Throws
1585
- // ContractViolationError{tool:'ggui_emit'} on violation;
1586
- // caller decides what to do (log, rethrow, wrap).
1587
- const stored = await opts.renderStore.get(delivery.renderId);
1588
- const activeEntry = stored?.render;
1589
- const streamSpec = activeEntry !== undefined && activeEntry.type !== "mcpApps" && activeEntry.type !== "system"
1590
- ? activeEntry.streamSpec
1591
- : undefined;
1592
- assertStreamContract(streamSpec, delivery.channel, delivery.payload, opts.extraReservedValidators);
1593
- return fanOut(delivery, streamSpec);
1594
- },
1595
- notifyRenderPush(renderId, render, matchType) {
1596
- // Best-effort fan-out to every live subscriber bound to this
1597
- // render. NOT routed through the replay buffer — see the
1598
- // `notifyRenderPush` JSDoc on the interface for why fresh
1599
- // subscribers rely on `ack.render` instead of a replay frame.
1600
- // NOT routed through StreamFanout either — `type: 'render'` is a
1601
- // distinct WebSocket message type. Filter the flat WS-subscriber
1602
- // set by renderId; N is typically 1-2 (multi-tab render sharing).
1603
- const payload = matchType !== undefined ? { render, matchType } : { render };
1604
- for (const sub of wsSubscribers) {
1605
- if (sub.renderId !== renderId)
1606
- continue;
1607
- send(sub.ws, { type: "render", payload });
1608
- }
1609
- },
1610
- async primeStreams(renderId, render) {
1611
- const router = opts.wiredActionRouter;
1612
- const streamSpec = "streamSpec" in render ? render.streamSpec : undefined;
1613
- if (!router || !streamSpec)
1614
- return;
1615
- const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
1616
- // Build the same wired-action ctx the dispatcher uses.
1617
- // Prime-time invocations reuse the seam so a refresh tool that
1618
- // fires `sendPropsUpdate` on cold-start works the same way as
1619
- // one fired post-action.
1620
- const wiredCtx = {
1621
- renderId,
1622
- sendPropsUpdate(props) {
1623
- void sendPropsUpdateImpl(renderId, props);
1624
- },
1625
- };
1626
- for (const [channelName, channelEntry] of Object.entries(streamSpec)) {
1627
- const refreshTool = channelEntry?.tool;
1628
- if (!refreshTool)
1629
- continue;
1630
- if (!router.has(refreshTool)) {
1631
- opts.logger.warn("render_channel_prime_tool_not_found", {
1632
- renderId,
1633
- toolName: refreshTool,
1634
- channel: channelName,
1635
- });
1636
- continue;
1637
- }
1638
- let output;
1639
- try {
1640
- output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
1641
- }
1642
- catch (err) {
1643
- opts.logger.warn("render_channel_prime_tool_failed", {
1644
- renderId,
1645
- toolName: refreshTool,
1646
- channel: channelName,
1647
- error: String(err),
1648
- });
1649
- continue;
1650
- }
1651
- try {
1652
- assertStreamContract(streamSpec, channelName, output, opts.extraReservedValidators);
1653
- }
1654
- catch (err) {
1655
- opts.logger.warn("render_channel_prime_schema_violation", {
1656
- renderId,
1657
- toolName: refreshTool,
1658
- channel: channelName,
1659
- error: String(err),
1660
- });
1661
- continue;
1662
- }
1663
- try {
1664
- await fanOut({
1665
- renderId,
1666
- channel: channelName,
1667
- mode: channelEntry?.mode ?? "append",
1668
- payload: output,
1669
- }, streamSpec);
1670
- }
1671
- catch (err) {
1672
- opts.logger.error("render_channel_prime_emit_failed", {
1673
- renderId,
1674
- toolName: refreshTool,
1675
- channel: channelName,
1676
- error: String(err),
1677
- });
1678
- }
1679
- }
1680
- },
1681
- sendPropsUpdate(renderId, props) {
1682
- // Public entry point — delegates to the closure-level impl that
1683
- // the wired-action dispatcher's `WiredActionContext.sendPropsUpdate`
1684
- // also calls. Returns the impl's promise so the caller can await
1685
- // store-lookup completion if desired (the wiredCtx call site
1686
- // fire-and-forgets via `void`).
1687
- return sendPropsUpdateImpl(renderId, props);
1688
- },
1689
- sendDrainAck({ renderId, appId, eventId, drainedAt }) {
1690
- // Server-side fan-out for the action-drain ack.
1691
- // Filter the flat WS-subscriber set by renderId (same posture
1692
- // as `sendPropsUpdate`). No persistence; subscribers that
1693
- // missed the frame fall back to their 10s claim timer, which
1694
- // the atomic pop resolves cleanly.
1695
- for (const sub of wsSubscribers) {
1696
- if (sub.renderId !== renderId)
1697
- continue;
1698
- send(sub.ws, {
1699
- type: "drain_ack",
1700
- payload: { renderId, appId, eventId, drainedAt },
1701
- });
1702
- }
1703
- },
1704
- externalBroadcast(renderId, frame) {
1705
- // Walk the flat subscriber set; filter to matching renderId.
1706
- // `send()` already guards closed sockets and logs (but doesn't
1707
- // throw on) per-subscriber failures, so the caller (a cloud
1708
- // pubsub on-message handler) can't be made to fail by a dead
1709
- // WebSocket. No RenderStore lookup — the publisher already
1710
- // validated; this seam is the cross-pod delivery path, not the
1711
- // re-validation point.
1712
- for (const sub of wsSubscribers) {
1713
- if (sub.renderId !== renderId)
1714
- continue;
1715
- send(sub.ws, frame);
1716
- }
1717
- },
1718
- get subscriberCount() {
1719
- return wsSubscribers.size;
1720
- },
1721
- get sessionCount() {
1722
- // Distinct session count across live WS subscribers. With
1723
- // multi-tab sessions, two subscribers may share a renderId —
1724
- // dedupe before counting.
1725
- const sessions = new Set();
1726
- for (const sub of wsSubscribers)
1727
- sessions.add(sub.renderId);
1728
- return sessions.size;
1729
- },
1730
- async close() {
1731
- // Close every open socket + drain its StreamFanout subscription.
1732
- // `wss.close` terminates the server but not in-flight sockets,
1733
- // so walk them explicitly. Each `iter.return()` unregisters
1734
- // the subscriber from the seam (idempotent on the in-process impl).
1735
- //
1736
- // Close code 1012 ("Service Restart", RFC 6455 + IANA registry)
1737
- // signals to clients that the server is restarting and they
1738
- // should reconnect immediately rather than treat the close as
1739
- // permanent. The pod's K8s rolling update fits this exactly:
1740
- // a new pod is already accepting connections behind the same
1741
- // load balancer; iframe-runtime + console viewer should
1742
- // reconnect on next message instead of blinking "disconnected".
1743
- // Code 1001 (used previously) means "endpoint going away" with
1744
- // no reconnect hint — semantically inaccurate for the pod-roll
1745
- // case and the wrong signal for client reconnect logic.
1746
- const sessions = new Set();
1747
- for (const sub of wsSubscribers) {
1748
- sessions.add(sub.renderId);
1749
- try {
1750
- sub.ws.close(1012, "service_restart");
1751
- }
1752
- catch {
1753
- /* best-effort */
1754
- }
1755
- void sub.iter.return?.();
1756
- }
1757
- wsSubscribers.clear();
1758
- // Defensive: also close any sessions on the seam that no longer
1759
- // have local WS subscribers (e.g. orphaned sessions from a partial
1760
- // unregister race). For InProcessStreamFanout this is a no-op
1761
- // when there are no subscribers; for hosted bindings it ensures
1762
- // the per-session pub/sub channel teardown fires.
1763
- await Promise.all(Array.from(sessions, (renderId) => streamFanout.close(renderId).catch(() => {
1764
- /* best-effort */
1765
- })));
1766
- await new Promise((resolve) => {
1767
- wss.close(() => resolve());
1768
- });
1769
- },
1770
- };
1771
- }
1772
- /** Fabricate a request id for live-channel ops so logs correlate. */
1773
- export function newRequestId() {
1774
- return randomUUID();
1775
- }