@ggui-ai/mcp-server 0.1.0-rc.1

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