@ggui-ai/mcp-server 0.1.0-rc.3 → 0.2.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build-mcp.d.ts +8 -8
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +53 -12
- package/dist/console-auth.d.ts +7 -7
- package/dist/console-auth.d.ts.map +1 -1
- package/dist/console-auth.js +4 -4
- package/dist/console-cache.d.ts +2 -2
- package/dist/console-cache.d.ts.map +1 -1
- package/dist/console-cache.js +10 -10
- package/dist/console-headers.d.ts +2 -2
- package/dist/console-headers.js +2 -2
- package/dist/console-payloads.d.ts +3 -3
- package/dist/console-payloads.d.ts.map +1 -1
- package/dist/console-payloads.js +10 -10
- package/dist/console-timeline.d.ts +12 -30
- package/dist/console-timeline.d.ts.map +1 -1
- package/dist/console-timeline.js +44 -45
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/instructions-presets.d.ts +1 -1
- package/dist/instructions-presets.d.ts.map +1 -1
- package/dist/instructions-presets.js +40 -22
- package/dist/llm-backed-negotiator.d.ts +6 -6
- package/dist/llm-backed-negotiator.d.ts.map +1 -1
- package/dist/llm-backed-negotiator.js +92 -91
- package/dist/mcp-apps-inbound.d.ts +3 -3
- package/dist/mcp-apps-inbound.d.ts.map +1 -1
- package/dist/mcp-apps-inbound.js +28 -23
- package/dist/mcp-apps-outbound.d.ts +136 -121
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +384 -414
- package/dist/mcp-mounts.d.ts +21 -20
- package/dist/mcp-mounts.d.ts.map +1 -1
- package/dist/mcp-mounts.js +25 -29
- package/dist/{session-channel.d.ts → render-channel.d.ts} +145 -102
- package/dist/render-channel.d.ts.map +1 -0
- package/dist/{session-channel.js → render-channel.js} +481 -462
- package/dist/schema-compat.d.ts +11 -11
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +6 -6
- package/dist/server.d.ts +224 -219
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1498 -1963
- package/dist/storage.d.ts +7 -7
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +12 -12
- package/package.json +12 -11
- package/dist/render-gate.d.ts +0 -87
- package/dist/render-gate.d.ts.map +0 -1
- package/dist/render-gate.js +0 -77
- package/dist/render-rate-limit.d.ts +0 -59
- package/dist/render-rate-limit.d.ts.map +0 -1
- package/dist/render-rate-limit.js +0 -73
- package/dist/render-signing.d.ts +0 -98
- package/dist/render-signing.d.ts.map +0 -1
- package/dist/render-signing.js +0 -113
- package/dist/session-channel.d.ts.map +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The live channel is where the typed-channel contract is enforced on
|
|
5
5
|
* live traffic between the server and the user. It co-hosts on the
|
|
6
6
|
* same Express server as `/mcp` and reuses the same
|
|
7
|
-
* `@ggui-ai/mcp-server-handlers/
|
|
7
|
+
* `@ggui-ai/mcp-server-handlers/renders` helpers that the
|
|
8
8
|
* closed hosted server consumes.
|
|
9
9
|
*
|
|
10
10
|
* Scope:
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
* - `action` → inbound user action carried as an {@link ActionEnvelope}.
|
|
15
15
|
* Gated through `assertEventAllowed` (allowlist) +
|
|
16
16
|
* `assertActionContract` (payload, for data:submit). Persisted to
|
|
17
|
-
*
|
|
17
|
+
* RenderStore as a typed session event.
|
|
18
18
|
* - `ping`/`pong` → heartbeat parity with hosted.
|
|
19
19
|
* - `close`/socket-close → clean subscriber teardown.
|
|
20
|
-
* - `sendToSession(
|
|
20
|
+
* - `sendToSession(renderId, data)` → outbound fan-out API for
|
|
21
21
|
* mutation handlers (ggui_emit / connector `ctx.send`). Validated
|
|
22
22
|
* through `assertStreamContract` before delivery.
|
|
23
23
|
*
|
|
24
24
|
* `props_update`: mount handlers dispatched through the wired-action
|
|
25
|
-
* router can call `ctx.sendPropsUpdate(
|
|
25
|
+
* router can call `ctx.sendPropsUpdate(renderId, props)` to fan a
|
|
26
26
|
* `{type:'props_update'}` frame to live subscribers without going
|
|
27
27
|
* through a refresh-stream path. Reaches the renderer's existing
|
|
28
28
|
* `props_update` branch in `iframe-runtime` and applies new props
|
|
@@ -32,31 +32,51 @@
|
|
|
32
32
|
* Not handled here:
|
|
33
33
|
*
|
|
34
34
|
* - Pattern-B daemon-agent notification stream (GET /mcp SSE).
|
|
35
|
-
* - Short-lived session-token mint/consume — that's
|
|
35
|
+
* - Short-lived session-token mint/consume — that's ggui_render-gated.
|
|
36
36
|
* Dev-mode auth (any bearer via existing AuthAdapter) matches the
|
|
37
37
|
* `/mcp` endpoint's shape and is operator-replaceable.
|
|
38
38
|
*/
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import { CONTRACT_ERROR_CHANNEL, ContractViolationError,
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import { resolveIdentityFromHeaders, UnauthenticatedError
|
|
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
|
+
}
|
|
45
65
|
/** Default URL path for the channel endpoint. Operators can override. */
|
|
46
|
-
export const
|
|
66
|
+
export const DEFAULT_RENDER_CHANNEL_PATH = "/ws";
|
|
47
67
|
/**
|
|
48
68
|
* Default + boundary cadence for the channel-subscribe polling loop.
|
|
49
69
|
* Server-authoritative: clients propose `pollIntervalMs` on
|
|
50
70
|
* `channel_subscribe`, server clamps to [floorMs, ceilingMs] and
|
|
51
71
|
* defaults to `defaultMs` when absent. Conservative defaults — operators
|
|
52
|
-
* tune via {@link
|
|
72
|
+
* tune via {@link RenderChannelLocalToolsOptions.pollCadence}.
|
|
53
73
|
*/
|
|
54
74
|
const DEFAULT_CHANNEL_POLL_FLOOR_MS = 1_000;
|
|
55
75
|
const DEFAULT_CHANNEL_POLL_CEILING_MS = 60_000;
|
|
56
76
|
const DEFAULT_CHANNEL_POLL_DEFAULT_MS = 10_000;
|
|
57
77
|
/**
|
|
58
78
|
* Default timeout for a single wired-tool invocation, in ms. Operators
|
|
59
|
-
* override via {@link
|
|
79
|
+
* override via {@link RenderChannelOptions.wiredActionTimeoutMs}; the
|
|
60
80
|
* 30 s ceiling is a honest non-promise: long-running tools MUST design
|
|
61
81
|
* their own completion path (streaming, polling).
|
|
62
82
|
*/
|
|
@@ -72,7 +92,7 @@ class WiredToolTimeoutError extends Error {
|
|
|
72
92
|
timeoutMs;
|
|
73
93
|
constructor(toolName, timeoutMs) {
|
|
74
94
|
super(`Wired tool '${toolName}' did not complete within ${timeoutMs}ms`);
|
|
75
|
-
this.name =
|
|
95
|
+
this.name = "WiredToolTimeoutError";
|
|
76
96
|
this.toolName = toolName;
|
|
77
97
|
this.timeoutMs = timeoutMs;
|
|
78
98
|
}
|
|
@@ -81,8 +101,8 @@ class WiredToolTimeoutError extends Error {
|
|
|
81
101
|
* Build an OSS live-channel server. The returned object is designed to be
|
|
82
102
|
* composed into `createGguiServer` — see `server.ts` for the wire-up.
|
|
83
103
|
*/
|
|
84
|
-
export function
|
|
85
|
-
const path = opts.path ??
|
|
104
|
+
export function createRenderChannelServer(opts) {
|
|
105
|
+
const path = opts.path ?? DEFAULT_RENDER_CHANNEL_PATH;
|
|
86
106
|
// Outbound stream buffer — owns seq assignment + bounded replay
|
|
87
107
|
// storage. Default is in-memory; operators swap via `opts.streamBuffer`.
|
|
88
108
|
const streamBuffer = opts.streamBuffer ?? new InMemorySessionStreamBuffer();
|
|
@@ -120,6 +140,15 @@ export function createSessionChannelServer(opts) {
|
|
|
120
140
|
const wsSubscribers = new Set();
|
|
121
141
|
/** ws → subscriber reverse index so socket-close can look up cheaply. */
|
|
122
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();
|
|
123
152
|
/**
|
|
124
153
|
* Pump live frames from the StreamFanout iterator out to this
|
|
125
154
|
* subscriber's WS. Started fire-and-forget by `register`; ends when
|
|
@@ -147,12 +176,12 @@ export function createSessionChannelServer(opts) {
|
|
|
147
176
|
await sub.iter.return?.();
|
|
148
177
|
return;
|
|
149
178
|
}
|
|
150
|
-
send(sub.ws, { type:
|
|
179
|
+
send(sub.ws, { type: "data", payload: value });
|
|
151
180
|
}
|
|
152
181
|
}
|
|
153
182
|
catch (err) {
|
|
154
|
-
opts.logger.warn(
|
|
155
|
-
|
|
183
|
+
opts.logger.warn("session_channel_pump_failed", {
|
|
184
|
+
renderId: sub.renderId,
|
|
156
185
|
error: String(err),
|
|
157
186
|
});
|
|
158
187
|
}
|
|
@@ -160,6 +189,24 @@ export function createSessionChannelServer(opts) {
|
|
|
160
189
|
function register(sub) {
|
|
161
190
|
wsSubscribers.add(sub);
|
|
162
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
|
+
}
|
|
163
210
|
// Start the pump loop. Fire-and-forget — pump errors are logged
|
|
164
211
|
// inside pumpSubscriber, never propagated.
|
|
165
212
|
void pumpSubscriber(sub);
|
|
@@ -170,6 +217,25 @@ export function createSessionChannelServer(opts) {
|
|
|
170
217
|
return;
|
|
171
218
|
subscribersByWs.delete(ws);
|
|
172
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
|
+
}
|
|
173
239
|
// Ending the iter terminates pumpSubscriber AND unregisters this
|
|
174
240
|
// subscriber from the StreamFanout. Idempotent on the seam side
|
|
175
241
|
// (close-after-return is a no-op).
|
|
@@ -190,12 +256,12 @@ export function createSessionChannelServer(opts) {
|
|
|
190
256
|
ws.send(JSON.stringify(msg));
|
|
191
257
|
}
|
|
192
258
|
catch (err) {
|
|
193
|
-
opts.logger.warn(
|
|
259
|
+
opts.logger.warn("session_channel_send_failed", { error: String(err) });
|
|
194
260
|
}
|
|
195
261
|
}
|
|
196
262
|
function sendError(ws, code, message, requestId, details) {
|
|
197
263
|
send(ws, {
|
|
198
|
-
type:
|
|
264
|
+
type: "error",
|
|
199
265
|
payload: { code, message, ...(details !== undefined ? { details } : {}) },
|
|
200
266
|
...(requestId ? { requestId } : {}),
|
|
201
267
|
});
|
|
@@ -206,20 +272,20 @@ export function createSessionChannelServer(opts) {
|
|
|
206
272
|
* AND emits the appropriate error frame when:
|
|
207
273
|
*
|
|
208
274
|
* - the socket has no bound subscriber (NOT_SUBSCRIBED)
|
|
209
|
-
* - payload.
|
|
275
|
+
* - payload.renderId doesn't match the subscriber binding
|
|
210
276
|
* (SESSION_MISMATCH)
|
|
211
277
|
*
|
|
212
278
|
* Subscriber binding is the authoritative tenancy scope. The wire
|
|
213
|
-
* payload's
|
|
279
|
+
* payload's renderId is belt-and-suspenders so the error message
|
|
214
280
|
* can be specific; appId narrows transparently via the binding.
|
|
215
281
|
*/
|
|
216
282
|
function checkSubscriberTenancy(ws, sub, payload, messageType, requestId) {
|
|
217
283
|
if (!sub) {
|
|
218
|
-
sendError(ws,
|
|
284
|
+
sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${messageType}'`, requestId);
|
|
219
285
|
return false;
|
|
220
286
|
}
|
|
221
|
-
if (payload.
|
|
222
|
-
sendError(ws,
|
|
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);
|
|
223
289
|
return false;
|
|
224
290
|
}
|
|
225
291
|
return true;
|
|
@@ -232,14 +298,14 @@ export function createSessionChannelServer(opts) {
|
|
|
232
298
|
* new shape; the next round-trip re-emits whatever the persistence
|
|
233
299
|
* layer lost.
|
|
234
300
|
*/
|
|
235
|
-
async function applySessionPatch(
|
|
301
|
+
async function applySessionPatch(renderId, appId, messageType, patch) {
|
|
236
302
|
try {
|
|
237
|
-
await opts.
|
|
303
|
+
await opts.renderStore.update(renderId, patch);
|
|
238
304
|
}
|
|
239
305
|
catch (err) {
|
|
240
|
-
opts.logger.warn(
|
|
306
|
+
opts.logger.warn("session_channel_observation_persist_failed", {
|
|
241
307
|
messageType,
|
|
242
|
-
|
|
308
|
+
renderId,
|
|
243
309
|
appId,
|
|
244
310
|
error: err instanceof Error ? err.message : String(err),
|
|
245
311
|
});
|
|
@@ -248,19 +314,19 @@ export function createSessionChannelServer(opts) {
|
|
|
248
314
|
/**
|
|
249
315
|
* Emit a `channel_error` frame to a specific subscriber. Used by the
|
|
250
316
|
* `channel_subscribe` handler for both subscribe-time rejections
|
|
251
|
-
* (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `
|
|
317
|
+
* (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `RENDER_NOT_FOUND`,
|
|
252
318
|
* `SUBSCRIBE_UNAUTHORIZED`) AND poll-time failures (`POLL_FAILED`).
|
|
253
319
|
*
|
|
254
320
|
* Direct-to-WS, not via fanOut — channel_error frames are
|
|
255
321
|
* per-subscriber and not stored in the replay buffer. A new
|
|
256
|
-
* subscriber on the same
|
|
322
|
+
* subscriber on the same render will re-subscribe and discover the
|
|
257
323
|
* same error itself.
|
|
258
324
|
*/
|
|
259
|
-
function sendChannelError(ws,
|
|
325
|
+
function sendChannelError(ws, renderId, channelName, code, message, requestId, details) {
|
|
260
326
|
send(ws, {
|
|
261
|
-
type:
|
|
327
|
+
type: "channel_error",
|
|
262
328
|
payload: {
|
|
263
|
-
|
|
329
|
+
renderId,
|
|
264
330
|
channelName,
|
|
265
331
|
code,
|
|
266
332
|
message,
|
|
@@ -276,7 +342,7 @@ export function createSessionChannelServer(opts) {
|
|
|
276
342
|
* docstring — clients propose, server clamps.
|
|
277
343
|
*/
|
|
278
344
|
function clampPollInterval(supplied) {
|
|
279
|
-
if (typeof supplied !==
|
|
345
|
+
if (typeof supplied !== "number" || !Number.isFinite(supplied)) {
|
|
280
346
|
return pollDefaultMs;
|
|
281
347
|
}
|
|
282
348
|
if (supplied < pollFloorMs)
|
|
@@ -310,11 +376,10 @@ export function createSessionChannelServer(opts) {
|
|
|
310
376
|
return;
|
|
311
377
|
state.seq += 1;
|
|
312
378
|
send(sub.ws, {
|
|
313
|
-
type:
|
|
379
|
+
type: "channel_payload",
|
|
314
380
|
payload: {
|
|
315
|
-
|
|
381
|
+
renderId: sub.renderId,
|
|
316
382
|
appId: sub.appId,
|
|
317
|
-
stackItemId: state.stackItemId,
|
|
318
383
|
channelName: state.channelName,
|
|
319
384
|
seq: state.seq,
|
|
320
385
|
ts: new Date().toISOString(),
|
|
@@ -323,7 +388,7 @@ export function createSessionChannelServer(opts) {
|
|
|
323
388
|
// append semantics declare `mode: 'append'` on streamSpec;
|
|
324
389
|
// honoring that is the iframe-runtime's concern at fold
|
|
325
390
|
// time. See `ChannelPayloadFrame.mode`.
|
|
326
|
-
mode:
|
|
391
|
+
mode: "replace",
|
|
327
392
|
payload: output,
|
|
328
393
|
},
|
|
329
394
|
});
|
|
@@ -331,14 +396,13 @@ export function createSessionChannelServer(opts) {
|
|
|
331
396
|
catch (err) {
|
|
332
397
|
if (sub.ws.readyState !== sub.ws.OPEN)
|
|
333
398
|
return;
|
|
334
|
-
sendChannelError(sub.ws, sub.
|
|
399
|
+
sendChannelError(sub.ws, sub.renderId, state.channelName, "POLL_FAILED", err instanceof Error ? err.message : String(err), undefined,
|
|
335
400
|
// causedBy slot — sanitized for credential safety in the same
|
|
336
401
|
// posture as wired-tool's TOOL_THREW emission.
|
|
337
402
|
sanitize(err instanceof Error ? (err.stack ?? err.message) : String(err)));
|
|
338
|
-
opts.logger.warn(
|
|
339
|
-
|
|
403
|
+
opts.logger.warn("session_channel_channel_poll_failed", {
|
|
404
|
+
renderId: sub.renderId,
|
|
340
405
|
appId: sub.appId,
|
|
341
|
-
stackItemId: state.stackItemId,
|
|
342
406
|
channelName: state.channelName,
|
|
343
407
|
toolName: state.toolName,
|
|
344
408
|
error: String(err),
|
|
@@ -349,63 +413,54 @@ export function createSessionChannelServer(opts) {
|
|
|
349
413
|
* Handle a `channel_subscribe` message. Validates the request,
|
|
350
414
|
* resolves the channel's `source.tool` against the configured
|
|
351
415
|
* allowlist, and schedules a polling loop. Idempotent on
|
|
352
|
-
* `${
|
|
416
|
+
* `${renderId}:${channelName}` — a re-subscribe replaces any
|
|
353
417
|
* existing interval rather than running two in parallel.
|
|
354
418
|
*/
|
|
355
419
|
async function handleChannelSubscribe(ws, sub, message) {
|
|
356
420
|
const payload = message.payload;
|
|
357
|
-
//
|
|
421
|
+
// renderId match — the spoof guard at every wire-input boundary.
|
|
358
422
|
// A subscriber bound to session A can't drive a subscribe for
|
|
359
423
|
// session B even if they crafted the inbound payload.
|
|
360
|
-
if (payload.
|
|
361
|
-
sendChannelError(ws, 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);
|
|
362
426
|
return;
|
|
363
427
|
}
|
|
364
428
|
// Without an `streamWebSocketLocalTools` allowlist, no channel
|
|
365
429
|
// can be subscribed locally. The iframe must fall back to direct
|
|
366
430
|
// polling via the MCP host proxy.
|
|
367
431
|
if (!localTools) {
|
|
368
|
-
sendChannelError(ws, payload.
|
|
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);
|
|
369
433
|
return;
|
|
370
434
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
376
|
-
sendChannelError(ws, payload.
|
|
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);
|
|
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);
|
|
387
441
|
return;
|
|
388
442
|
}
|
|
443
|
+
const render = stored.render;
|
|
389
444
|
// Channel entry resolution. mcpApps / system variants have no
|
|
390
445
|
// streamSpec so the field reads back as undefined — same code
|
|
391
446
|
// path as a component variant without the channel declared.
|
|
392
|
-
const streamSpec =
|
|
447
|
+
const streamSpec = render.type === "mcpApps" || render.type === "system"
|
|
393
448
|
? undefined
|
|
394
|
-
:
|
|
449
|
+
: render.streamSpec;
|
|
395
450
|
const channelEntry = streamSpec?.[payload.channelName];
|
|
396
451
|
if (!channelEntry || !channelEntry.source) {
|
|
397
|
-
sendChannelError(ws, payload.
|
|
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);
|
|
398
453
|
return;
|
|
399
454
|
}
|
|
400
455
|
const sourceTool = channelEntry.source.tool;
|
|
401
456
|
if (!localToolsAllowlist.has(sourceTool)) {
|
|
402
|
-
sendChannelError(ws, payload.
|
|
457
|
+
sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_NOT_LOCAL", `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
|
|
403
458
|
return;
|
|
404
459
|
}
|
|
405
460
|
// Validation passed — schedule (or re-schedule) the polling loop.
|
|
406
|
-
const channelKey = `${payload.
|
|
461
|
+
const channelKey = `${payload.renderId}:${payload.channelName}`;
|
|
407
462
|
// Idempotent replace: a reconnect that re-subscribes the same
|
|
408
|
-
// (
|
|
463
|
+
// (render, channel) pair gets a fresh timer + zeroed seq. The
|
|
409
464
|
// client's gap-detection treats it as a new stream from the
|
|
410
465
|
// server's perspective; client-side reconnect logic owns
|
|
411
466
|
// continuity if the channel was declared `mode: 'append'`.
|
|
@@ -439,17 +494,16 @@ export function createSessionChannelServer(opts) {
|
|
|
439
494
|
const state = {
|
|
440
495
|
pollIntervalMs,
|
|
441
496
|
toolName: sourceTool,
|
|
442
|
-
|
|
497
|
+
renderId: payload.renderId,
|
|
443
498
|
channelName: payload.channelName,
|
|
444
499
|
args: mergedArgs,
|
|
445
500
|
seq: 0,
|
|
446
501
|
timer,
|
|
447
502
|
};
|
|
448
503
|
sub.channelSubs.set(channelKey, state);
|
|
449
|
-
opts.logger.info(
|
|
450
|
-
|
|
504
|
+
opts.logger.info("render_channel_channel_subscribe", {
|
|
505
|
+
renderId: sub.renderId,
|
|
451
506
|
appId: sub.appId,
|
|
452
|
-
stackItemId: payload.stackItemId,
|
|
453
507
|
channelName: payload.channelName,
|
|
454
508
|
toolName: sourceTool,
|
|
455
509
|
pollIntervalMs,
|
|
@@ -468,22 +522,21 @@ export function createSessionChannelServer(opts) {
|
|
|
468
522
|
*/
|
|
469
523
|
function handleChannelUnsubscribe(_ws, sub, message) {
|
|
470
524
|
const payload = message.payload;
|
|
471
|
-
if (payload.
|
|
525
|
+
if (payload.renderId !== sub.renderId) {
|
|
472
526
|
// No-op silently — the canonical "spoof guard" code path is in
|
|
473
527
|
// channel_subscribe; unsubscribe gets no error frame to avoid
|
|
474
528
|
// leaking cross-session existence.
|
|
475
529
|
return;
|
|
476
530
|
}
|
|
477
|
-
const channelKey = `${payload.
|
|
531
|
+
const channelKey = `${payload.renderId}:${payload.channelName}`;
|
|
478
532
|
const existing = sub.channelSubs.get(channelKey);
|
|
479
533
|
if (!existing)
|
|
480
534
|
return;
|
|
481
535
|
clearInterval(existing.timer);
|
|
482
536
|
sub.channelSubs.delete(channelKey);
|
|
483
|
-
opts.logger.info(
|
|
484
|
-
|
|
537
|
+
opts.logger.info("render_channel_channel_unsubscribe", {
|
|
538
|
+
renderId: sub.renderId,
|
|
485
539
|
appId: sub.appId,
|
|
486
|
-
stackItemId: payload.stackItemId,
|
|
487
540
|
channelName: payload.channelName,
|
|
488
541
|
});
|
|
489
542
|
}
|
|
@@ -510,7 +563,7 @@ export function createSessionChannelServer(opts) {
|
|
|
510
563
|
// because publish() never throws on the in-process impl, and a hosted
|
|
511
564
|
// RedisPubSubFanout failure here would already be persisted to the
|
|
512
565
|
// SessionStreamBuffer for replay-recovery on reconnect.
|
|
513
|
-
void streamFanout.publish({
|
|
566
|
+
void streamFanout.publish({ renderId: envelope.renderId, envelope });
|
|
514
567
|
return { seq: envelope.seq };
|
|
515
568
|
}
|
|
516
569
|
/**
|
|
@@ -519,7 +572,7 @@ export function createSessionChannelServer(opts) {
|
|
|
519
572
|
* dispatch path calls this from `catch` branches; a second failure
|
|
520
573
|
* would be a footgun.
|
|
521
574
|
*/
|
|
522
|
-
function emitContractError(
|
|
575
|
+
function emitContractError(renderId, activeStreamSpec, payload) {
|
|
523
576
|
try {
|
|
524
577
|
// Central stamp — re-emit through makeContractErrorPayload so
|
|
525
578
|
// every contract-error envelope carries the current stamped
|
|
@@ -533,12 +586,8 @@ export function createSessionChannelServer(opts) {
|
|
|
533
586
|
toolName: payload.toolName,
|
|
534
587
|
error: payload.error,
|
|
535
588
|
timestamp: payload.timestamp,
|
|
536
|
-
...(payload.actionName !== undefined
|
|
537
|
-
|
|
538
|
-
: {}),
|
|
539
|
-
...(payload.sourceAction !== undefined
|
|
540
|
-
? { sourceAction: payload.sourceAction }
|
|
541
|
-
: {}),
|
|
589
|
+
...(payload.actionName !== undefined ? { actionName: payload.actionName } : {}),
|
|
590
|
+
...(payload.sourceAction !== undefined ? { sourceAction: payload.sourceAction } : {}),
|
|
542
591
|
});
|
|
543
592
|
// Fire-and-forget: emitContractError is called from sync `catch`
|
|
544
593
|
// branches and a second async failure here would be a footgun.
|
|
@@ -546,13 +595,13 @@ export function createSessionChannelServer(opts) {
|
|
|
546
595
|
// says publish never throws on InProcess impl, and a hosted
|
|
547
596
|
// RedisPubSubFanout failure is recoverable via replay-on-reconnect.
|
|
548
597
|
void fanOut({
|
|
549
|
-
|
|
598
|
+
renderId,
|
|
550
599
|
channel: CONTRACT_ERROR_CHANNEL,
|
|
551
|
-
mode:
|
|
600
|
+
mode: "append",
|
|
552
601
|
payload: stamped,
|
|
553
602
|
}, activeStreamSpec).catch((err) => {
|
|
554
|
-
opts.logger.error(
|
|
555
|
-
|
|
603
|
+
opts.logger.error("session_channel_contract_error_emit_failed", {
|
|
604
|
+
renderId,
|
|
556
605
|
toolName: payload.toolName,
|
|
557
606
|
code: payload.error.code,
|
|
558
607
|
error: String(err),
|
|
@@ -560,8 +609,8 @@ export function createSessionChannelServer(opts) {
|
|
|
560
609
|
});
|
|
561
610
|
}
|
|
562
611
|
catch (err) {
|
|
563
|
-
opts.logger.error(
|
|
564
|
-
|
|
612
|
+
opts.logger.error("session_channel_contract_error_emit_failed", {
|
|
613
|
+
renderId,
|
|
565
614
|
toolName: payload.toolName,
|
|
566
615
|
code: payload.error.code,
|
|
567
616
|
error: String(err),
|
|
@@ -598,52 +647,41 @@ export function createSessionChannelServer(opts) {
|
|
|
598
647
|
}
|
|
599
648
|
}
|
|
600
649
|
/**
|
|
601
|
-
* Internal impl behind the public {@link
|
|
650
|
+
* Internal impl behind the public {@link RenderChannelServer.sendPropsUpdate}.
|
|
602
651
|
* Extracted as a closure-level function so the wired-action dispatcher
|
|
603
652
|
* can build a `WiredActionContext.sendPropsUpdate` that closes over the
|
|
604
653
|
* same logic without forward-referencing the returned object. Best-
|
|
605
654
|
* effort + orphan-tolerant per the docstring on the public method.
|
|
606
655
|
*/
|
|
607
|
-
async function sendPropsUpdateImpl(
|
|
608
|
-
let
|
|
656
|
+
async function sendPropsUpdateImpl(renderId, props) {
|
|
657
|
+
let stored;
|
|
609
658
|
try {
|
|
610
|
-
|
|
659
|
+
stored = await opts.renderStore.get(renderId);
|
|
611
660
|
}
|
|
612
661
|
catch (err) {
|
|
613
|
-
opts.logger.warn(
|
|
614
|
-
|
|
615
|
-
stackItemId,
|
|
662
|
+
opts.logger.warn("render_channel_props_update_lookup_failed", {
|
|
663
|
+
renderId,
|
|
616
664
|
error: String(err),
|
|
617
665
|
});
|
|
618
666
|
return;
|
|
619
667
|
}
|
|
620
|
-
if (!
|
|
621
|
-
opts.logger.warn(
|
|
622
|
-
|
|
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,
|
|
668
|
+
if (!stored) {
|
|
669
|
+
opts.logger.warn("render_channel_props_update_orphan", {
|
|
670
|
+
renderId,
|
|
633
671
|
});
|
|
634
672
|
return;
|
|
635
673
|
}
|
|
636
|
-
// Filter the flat WS-subscriber set by
|
|
637
|
-
// `
|
|
674
|
+
// Filter the flat WS-subscriber set by renderId; same posture as
|
|
675
|
+
// `notifyRenderPush`. `send()` already silently skips closed sockets
|
|
638
676
|
// and logs (but doesn't throw on) per-subscriber send failures, so
|
|
639
677
|
// the caller's mount-handler path can't be made to fail by a dead
|
|
640
678
|
// WebSocket.
|
|
641
679
|
for (const sub of wsSubscribers) {
|
|
642
|
-
if (sub.
|
|
680
|
+
if (sub.renderId !== renderId)
|
|
643
681
|
continue;
|
|
644
682
|
send(sub.ws, {
|
|
645
|
-
type:
|
|
646
|
-
payload: {
|
|
683
|
+
type: "props_update",
|
|
684
|
+
payload: { renderId, props },
|
|
647
685
|
});
|
|
648
686
|
}
|
|
649
687
|
}
|
|
@@ -667,12 +705,17 @@ export function createSessionChannelServer(opts) {
|
|
|
667
705
|
* need filter args should inline the action + refresh into a single
|
|
668
706
|
* tool that returns the new state.
|
|
669
707
|
*/
|
|
670
|
-
async function dispatchWiredAction(
|
|
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;
|
|
671
714
|
const router = opts.wiredActionRouter;
|
|
672
|
-
if (!router || !activeItem || envelope.type !==
|
|
715
|
+
if (!router || !activeItem || envelope.type !== "data:submit")
|
|
673
716
|
return;
|
|
674
717
|
const payload = envelope.payload;
|
|
675
|
-
if (!payload || typeof payload.action !==
|
|
718
|
+
if (!payload || typeof payload.action !== "string")
|
|
676
719
|
return;
|
|
677
720
|
// Disagreement policy — the server-side enforcement point for the
|
|
678
721
|
// `client wins` rule documented on `ActionEventValue.tool`. Prefer
|
|
@@ -683,32 +726,34 @@ export function createSessionChannelServer(opts) {
|
|
|
683
726
|
// client is the source of truth for what the user actually saw.
|
|
684
727
|
// Cross-validation against the agent's tracked contract happens on
|
|
685
728
|
// the agent-SDK side, not here.
|
|
686
|
-
|
|
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];
|
|
687
733
|
const serverDeclaredTool = actionEntry?.nextStep;
|
|
688
|
-
const declaredTool = (typeof payload.tool ===
|
|
689
|
-
|
|
690
|
-
: undefined) ?? serverDeclaredTool;
|
|
734
|
+
const declaredTool = (typeof payload.tool === "string" && payload.tool.length > 0 ? payload.tool : undefined) ??
|
|
735
|
+
serverDeclaredTool;
|
|
691
736
|
if (!declaredTool)
|
|
692
737
|
return;
|
|
693
738
|
const actionName = payload.action;
|
|
694
|
-
const input = payload.data && typeof payload.data ===
|
|
739
|
+
const input = payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)
|
|
695
740
|
? payload.data
|
|
696
741
|
: {};
|
|
697
742
|
const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
|
|
698
|
-
const streamSpec =
|
|
743
|
+
const streamSpec = componentItem?.streamSpec;
|
|
699
744
|
if (!router.has(declaredTool)) {
|
|
700
745
|
emitContractError(session.id, streamSpec, {
|
|
701
746
|
toolName: declaredTool,
|
|
702
747
|
actionName,
|
|
703
|
-
sourceAction: { type:
|
|
748
|
+
sourceAction: { type: "wired-action", dispatchedAt },
|
|
704
749
|
error: {
|
|
705
|
-
code:
|
|
750
|
+
code: "TOOL_NOT_FOUND",
|
|
706
751
|
message: `wiredActionRouter has no handler for tool '${declaredTool}'`,
|
|
707
752
|
},
|
|
708
753
|
timestamp: new Date().toISOString(),
|
|
709
754
|
});
|
|
710
|
-
opts.logger.warn(
|
|
711
|
-
|
|
755
|
+
opts.logger.warn("session_channel_wired_tool_not_found", {
|
|
756
|
+
renderId: session.id,
|
|
712
757
|
toolName: declaredTool,
|
|
713
758
|
actionName,
|
|
714
759
|
});
|
|
@@ -717,15 +762,14 @@ export function createSessionChannelServer(opts) {
|
|
|
717
762
|
// Build the wired-action context the router hands the mount tool.
|
|
718
763
|
// `sendPropsUpdate` closes over the active
|
|
719
764
|
// `session.id` so a buggy mount can't accidentally cross-deliver to
|
|
720
|
-
// another session by passing a foreign
|
|
765
|
+
// another session by passing a foreign renderId. The same ctx is
|
|
721
766
|
// reused for the refresh-stream pass below — a refresh tool that
|
|
722
767
|
// wants to fire props_update can do so, though the canonical site
|
|
723
768
|
// is the action tool itself.
|
|
724
769
|
const wiredCtx = {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
void sendPropsUpdateImpl(session.id, targetStackItemId, props);
|
|
770
|
+
renderId: session.id,
|
|
771
|
+
sendPropsUpdate(props) {
|
|
772
|
+
void sendPropsUpdateImpl(session.id, props);
|
|
729
773
|
},
|
|
730
774
|
};
|
|
731
775
|
const invokeStartedAt = Date.now();
|
|
@@ -733,22 +777,20 @@ export function createSessionChannelServer(opts) {
|
|
|
733
777
|
await invokeWithTimeout(router, declaredTool, input, wiredCtx, timeoutMs);
|
|
734
778
|
}
|
|
735
779
|
catch (err) {
|
|
736
|
-
const code = err instanceof WiredToolTimeoutError ?
|
|
780
|
+
const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
|
|
737
781
|
emitContractError(session.id, streamSpec, {
|
|
738
782
|
toolName: declaredTool,
|
|
739
783
|
actionName,
|
|
740
|
-
sourceAction: { type:
|
|
784
|
+
sourceAction: { type: "wired-action", dispatchedAt },
|
|
741
785
|
error: {
|
|
742
786
|
code,
|
|
743
787
|
message: err instanceof Error ? err.message : String(err),
|
|
744
|
-
...(err instanceof Error && err.stack
|
|
745
|
-
? { causedBy: sanitize(err.stack) }
|
|
746
|
-
: {}),
|
|
788
|
+
...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
|
|
747
789
|
},
|
|
748
790
|
timestamp: new Date().toISOString(),
|
|
749
791
|
});
|
|
750
|
-
opts.logger.warn(
|
|
751
|
-
|
|
792
|
+
opts.logger.warn("session_channel_wired_tool_failed", {
|
|
793
|
+
renderId: session.id,
|
|
752
794
|
toolName: declaredTool,
|
|
753
795
|
actionName,
|
|
754
796
|
code,
|
|
@@ -761,12 +803,12 @@ export function createSessionChannelServer(opts) {
|
|
|
761
803
|
// sink MUST NOT block the refresh pass. Attribute set is kept
|
|
762
804
|
// flat + primitive-only per TelemetryEvent.attributes shape.
|
|
763
805
|
telemetry.emit({
|
|
764
|
-
name:
|
|
806
|
+
name: "wired-tool.invoked",
|
|
765
807
|
at: Date.now(),
|
|
766
808
|
attributes: {
|
|
767
809
|
toolName: declaredTool,
|
|
768
810
|
actionName,
|
|
769
|
-
|
|
811
|
+
renderId: session.id,
|
|
770
812
|
latencyMs: Date.now() - invokeStartedAt,
|
|
771
813
|
},
|
|
772
814
|
});
|
|
@@ -784,9 +826,9 @@ export function createSessionChannelServer(opts) {
|
|
|
784
826
|
emitContractError(session.id, streamSpec, {
|
|
785
827
|
toolName: refreshTool,
|
|
786
828
|
actionName,
|
|
787
|
-
sourceAction: { type:
|
|
829
|
+
sourceAction: { type: "refresh-stream", dispatchedAt },
|
|
788
830
|
error: {
|
|
789
|
-
code:
|
|
831
|
+
code: "TOOL_NOT_FOUND",
|
|
790
832
|
message: `wiredActionRouter has no handler for refresh tool '${refreshTool}' (channel '${channelName}')`,
|
|
791
833
|
},
|
|
792
834
|
timestamp: new Date().toISOString(),
|
|
@@ -803,22 +845,20 @@ export function createSessionChannelServer(opts) {
|
|
|
803
845
|
output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
|
|
804
846
|
}
|
|
805
847
|
catch (err) {
|
|
806
|
-
const code = err instanceof WiredToolTimeoutError ?
|
|
848
|
+
const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
|
|
807
849
|
emitContractError(session.id, streamSpec, {
|
|
808
850
|
toolName: refreshTool,
|
|
809
851
|
actionName,
|
|
810
|
-
sourceAction: { type:
|
|
852
|
+
sourceAction: { type: "refresh-stream", dispatchedAt },
|
|
811
853
|
error: {
|
|
812
854
|
code,
|
|
813
855
|
message: err instanceof Error ? err.message : String(err),
|
|
814
|
-
...(err instanceof Error && err.stack
|
|
815
|
-
? { causedBy: sanitize(err.stack) }
|
|
816
|
-
: {}),
|
|
856
|
+
...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
|
|
817
857
|
},
|
|
818
858
|
timestamp: new Date().toISOString(),
|
|
819
859
|
});
|
|
820
|
-
opts.logger.warn(
|
|
821
|
-
|
|
860
|
+
opts.logger.warn("session_channel_refresh_tool_failed", {
|
|
861
|
+
renderId: session.id,
|
|
822
862
|
toolName: refreshTool,
|
|
823
863
|
channel: channelName,
|
|
824
864
|
code,
|
|
@@ -834,15 +874,15 @@ export function createSessionChannelServer(opts) {
|
|
|
834
874
|
emitContractError(session.id, streamSpec, {
|
|
835
875
|
toolName: refreshTool,
|
|
836
876
|
actionName,
|
|
837
|
-
sourceAction: { type:
|
|
877
|
+
sourceAction: { type: "refresh-stream", dispatchedAt },
|
|
838
878
|
error: {
|
|
839
|
-
code:
|
|
879
|
+
code: "SCHEMA_VIOLATION",
|
|
840
880
|
message: err.message,
|
|
841
881
|
},
|
|
842
882
|
timestamp: new Date().toISOString(),
|
|
843
883
|
});
|
|
844
|
-
opts.logger.warn(
|
|
845
|
-
|
|
884
|
+
opts.logger.warn("session_channel_refresh_schema_violation", {
|
|
885
|
+
renderId: session.id,
|
|
846
886
|
toolName: refreshTool,
|
|
847
887
|
channel: channelName,
|
|
848
888
|
violations: err.violations,
|
|
@@ -853,9 +893,9 @@ export function createSessionChannelServer(opts) {
|
|
|
853
893
|
}
|
|
854
894
|
try {
|
|
855
895
|
await fanOut({
|
|
856
|
-
|
|
896
|
+
renderId: session.id,
|
|
857
897
|
channel: channelName,
|
|
858
|
-
mode: channelEntry?.mode ??
|
|
898
|
+
mode: channelEntry?.mode ?? "append",
|
|
859
899
|
payload: output,
|
|
860
900
|
}, streamSpec);
|
|
861
901
|
}
|
|
@@ -864,8 +904,8 @@ export function createSessionChannelServer(opts) {
|
|
|
864
904
|
// is buffer-internal (e.g., record() invariant violation). Log
|
|
865
905
|
// but don't propagate — a single broken channel must not take
|
|
866
906
|
// down the session.
|
|
867
|
-
opts.logger.error(
|
|
868
|
-
|
|
907
|
+
opts.logger.error("session_channel_refresh_emit_failed", {
|
|
908
|
+
renderId: session.id,
|
|
869
909
|
toolName: refreshTool,
|
|
870
910
|
channel: channelName,
|
|
871
911
|
error: String(err),
|
|
@@ -874,40 +914,40 @@ export function createSessionChannelServer(opts) {
|
|
|
874
914
|
}
|
|
875
915
|
}
|
|
876
916
|
async function resolveIdentityFromUpgrade(req) {
|
|
877
|
-
const url = new URL(req.url ??
|
|
878
|
-
//
|
|
879
|
-
// configured with
|
|
880
|
-
// at upgrade. The real identity is established in
|
|
881
|
-
// when the subscribe payload's `
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
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.
|
|
885
925
|
//
|
|
886
926
|
// URL-gate here is NOT verification — it's a "don't reject the
|
|
887
927
|
// upgrade for missing bearer" signal. The real verify runs at
|
|
888
928
|
// subscribe time; an invalid token reaches that point and is
|
|
889
929
|
// rejected with BOOTSTRAP_INVALID.
|
|
890
|
-
if (opts.bootstrap && url.searchParams.has(
|
|
930
|
+
if (opts.bootstrap && url.searchParams.has("wsToken")) {
|
|
891
931
|
return {
|
|
892
932
|
identity: {
|
|
893
|
-
kind:
|
|
894
|
-
userId:
|
|
895
|
-
workspaceId:
|
|
933
|
+
kind: "user",
|
|
934
|
+
userId: "__bootstrap_pending__",
|
|
935
|
+
workspaceId: "__bootstrap_pending__",
|
|
896
936
|
roles: [],
|
|
897
937
|
},
|
|
898
|
-
source:
|
|
938
|
+
source: "apikey",
|
|
899
939
|
};
|
|
900
940
|
}
|
|
901
941
|
// Embedded-ui cookie gate. Consulted ONLY when the channel is
|
|
902
942
|
// configured with cookie-auth plumbing AND no bootstrap is in
|
|
903
943
|
// play. Unlike bootstrap, cookies ARE verified here: the single
|
|
904
944
|
// consumer (console SPA) sets the cookie out-of-band via
|
|
905
|
-
// `POST /ggui/console/
|
|
945
|
+
// `POST /ggui/console/render-cookie` and we do want to
|
|
906
946
|
// reject the upgrade cleanly (HTTP 401 → browser WS error) when
|
|
907
947
|
// the cookie is stale/missing, not carry a doomed handshake into
|
|
908
948
|
// subscribe where the error surface is worse.
|
|
909
949
|
//
|
|
910
|
-
// On success, we stash the bound `{
|
|
950
|
+
// On success, we stash the bound `{renderId, appId}` on the
|
|
911
951
|
// request so `handleSubscribe` can enforce that the subscribe
|
|
912
952
|
// payload targets exactly those values. No synthesis from the
|
|
913
953
|
// AuthAdapter — cookies ARE the auth signal.
|
|
@@ -918,14 +958,14 @@ export function createSessionChannelServer(opts) {
|
|
|
918
958
|
if (bound) {
|
|
919
959
|
req.__gguiCookieBound = bound;
|
|
920
960
|
return {
|
|
921
|
-
identity: { kind:
|
|
922
|
-
source:
|
|
961
|
+
identity: { kind: "builder" },
|
|
962
|
+
source: "apikey",
|
|
923
963
|
};
|
|
924
964
|
}
|
|
925
965
|
// Cookie present but invalid — do NOT fall through to the
|
|
926
966
|
// bearer path. An invalid cookie is a same-origin user error,
|
|
927
967
|
// not a pass-through condition.
|
|
928
|
-
throw new UnauthenticatedError(
|
|
968
|
+
throw new UnauthenticatedError("console cookie invalid");
|
|
929
969
|
}
|
|
930
970
|
// No cookie present → fall through to bearer path below. Mixed
|
|
931
971
|
// deployments (pairing-token bearer + same-origin cookie for
|
|
@@ -935,33 +975,28 @@ export function createSessionChannelServer(opts) {
|
|
|
935
975
|
// to `?token=<jwt>` for web clients, matching the convention most
|
|
936
976
|
// session-channel endpoints ship with. Server-side clients (Node
|
|
937
977
|
// `ws`, tests) continue to set the header directly.
|
|
938
|
-
if (!req.headers[
|
|
939
|
-
const token = url.searchParams.get(
|
|
978
|
+
if (!req.headers["authorization"]) {
|
|
979
|
+
const token = url.searchParams.get("token");
|
|
940
980
|
if (token) {
|
|
941
|
-
req.headers[
|
|
981
|
+
req.headers["authorization"] = `Bearer ${token}`;
|
|
942
982
|
}
|
|
943
983
|
}
|
|
944
984
|
return resolveIdentityFromHeaders(opts.auth, req.headers, req.socket?.remoteAddress ?? undefined);
|
|
945
985
|
}
|
|
946
986
|
/**
|
|
947
|
-
* Resolve the
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
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).
|
|
953
993
|
*/
|
|
954
|
-
function
|
|
955
|
-
if (
|
|
994
|
+
function resolveActiveRender(render) {
|
|
995
|
+
if (!render)
|
|
956
996
|
return undefined;
|
|
957
|
-
|
|
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')
|
|
997
|
+
if (render.type === "mcpApps" || render.type === "system")
|
|
963
998
|
return undefined;
|
|
964
|
-
return
|
|
999
|
+
return render;
|
|
965
1000
|
}
|
|
966
1001
|
/**
|
|
967
1002
|
* Handle an inbound `action` message — the canonical flat
|
|
@@ -975,83 +1010,80 @@ export function createSessionChannelServer(opts) {
|
|
|
975
1010
|
*/
|
|
976
1011
|
async function handleInboundAction(ws, sub, message) {
|
|
977
1012
|
const envelope = message.payload;
|
|
978
|
-
// Spoof guard — envelope.
|
|
1013
|
+
// Spoof guard — envelope.renderId is REQUIRED on the wire and
|
|
979
1014
|
// MUST match the subscriber's bound session.
|
|
980
|
-
if (envelope.
|
|
981
|
-
sendError(ws,
|
|
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);
|
|
982
1017
|
return;
|
|
983
1018
|
}
|
|
984
|
-
const
|
|
985
|
-
if (!
|
|
986
|
-
sendError(ws,
|
|
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);
|
|
987
1022
|
return;
|
|
988
1023
|
}
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
|
|
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);
|
|
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);
|
|
1000
1028
|
// ── Two-step enforcement ──
|
|
1001
|
-
// 1. allowlist via assertEventAllowed
|
|
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.)
|
|
1002
1032
|
// 2. actionSpec payload check via assertActionContract (data:submit)
|
|
1003
1033
|
// Envelope.payload for data:submit carries the ActionEventValue
|
|
1004
1034
|
// shape (`{action, data?, tool?}`).
|
|
1005
1035
|
try {
|
|
1006
|
-
assertEventAllowed(
|
|
1036
|
+
assertEventAllowed(undefined, envelope.type);
|
|
1007
1037
|
}
|
|
1008
1038
|
catch (err) {
|
|
1009
1039
|
if (err instanceof EventNotAllowedError) {
|
|
1010
|
-
opts.logger.warn(
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
envelope: 'action',
|
|
1040
|
+
opts.logger.warn("render_channel_event_not_allowed", {
|
|
1041
|
+
renderId: sub.renderId,
|
|
1042
|
+
envelope: "action",
|
|
1043
|
+
error: err.message,
|
|
1015
1044
|
});
|
|
1016
|
-
sendError(ws,
|
|
1045
|
+
sendError(ws, "EVENT_NOT_ALLOWED", err.message, message.requestId);
|
|
1017
1046
|
return;
|
|
1018
1047
|
}
|
|
1019
1048
|
throw err;
|
|
1020
1049
|
}
|
|
1021
|
-
if (envelope.type ===
|
|
1050
|
+
if (envelope.type === "data:submit") {
|
|
1022
1051
|
try {
|
|
1023
|
-
|
|
1052
|
+
const activeActionSpec = activeItem && activeItem.type !== "mcpApps" && activeItem.type !== "system"
|
|
1053
|
+
? activeItem.actionSpec
|
|
1054
|
+
: undefined;
|
|
1055
|
+
assertActionContract(activeActionSpec, envelope.payload);
|
|
1024
1056
|
}
|
|
1025
1057
|
catch (err) {
|
|
1026
1058
|
if (err instanceof ContractViolationError) {
|
|
1027
|
-
opts.logger.warn(
|
|
1028
|
-
|
|
1059
|
+
opts.logger.warn("session_channel_contract_violation", {
|
|
1060
|
+
renderId: sub.renderId,
|
|
1029
1061
|
violations: err.violations,
|
|
1030
|
-
envelope:
|
|
1062
|
+
envelope: "action",
|
|
1031
1063
|
});
|
|
1032
|
-
sendError(ws,
|
|
1064
|
+
sendError(ws, "CONTRACT_VIOLATION", err.message, message.requestId, err.toErrorData());
|
|
1033
1065
|
return;
|
|
1034
1066
|
}
|
|
1035
1067
|
throw err;
|
|
1036
1068
|
}
|
|
1037
1069
|
}
|
|
1038
|
-
// Persist the envelope.
|
|
1070
|
+
// Persist the envelope. RenderStore.appendEvent assigns a monotonic
|
|
1039
1071
|
// seq the client acks back with so reconnects can resume via `fromSeq`.
|
|
1040
1072
|
const dispatchedAt = new Date().toISOString();
|
|
1041
1073
|
let seq;
|
|
1042
1074
|
try {
|
|
1043
|
-
seq = await opts.
|
|
1044
|
-
|
|
1045
|
-
type:
|
|
1075
|
+
seq = await opts.renderStore.appendEvent({
|
|
1076
|
+
renderId: sub.renderId,
|
|
1077
|
+
type: "user.submitted",
|
|
1046
1078
|
data: envelope,
|
|
1047
1079
|
});
|
|
1048
1080
|
}
|
|
1049
1081
|
catch (err) {
|
|
1050
|
-
opts.logger.error(
|
|
1051
|
-
|
|
1082
|
+
opts.logger.error("session_channel_append_failed", {
|
|
1083
|
+
renderId: sub.renderId,
|
|
1052
1084
|
error: String(err),
|
|
1053
1085
|
});
|
|
1054
|
-
sendError(ws,
|
|
1086
|
+
sendError(ws, "APPEND_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
|
|
1055
1087
|
return;
|
|
1056
1088
|
}
|
|
1057
1089
|
// wiredActionRouter — fire any declared action-tool +
|
|
@@ -1065,7 +1097,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1065
1097
|
// catches every failure and emits contract-error envelopes; a
|
|
1066
1098
|
// throw out of this call would be a platform bug and we'd rather
|
|
1067
1099
|
// see it in tests than silently drop.
|
|
1068
|
-
await dispatchWiredAction(
|
|
1100
|
+
await dispatchWiredAction(stored, activeItem, envelope, dispatchedAt);
|
|
1069
1101
|
// StreamFanout pump-drain: dispatchWiredAction's internal fanOut
|
|
1070
1102
|
// calls `streamFanout.publish()` which queues envelopes into the
|
|
1071
1103
|
// pump's async iterator. The pump's `await iter.next()` resolves
|
|
@@ -1079,7 +1111,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1079
1111
|
// independent signals.
|
|
1080
1112
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1081
1113
|
send(ws, {
|
|
1082
|
-
type:
|
|
1114
|
+
type: "ack",
|
|
1083
1115
|
payload: { sequence: seq, timestamp: Date.now() },
|
|
1084
1116
|
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
1085
1117
|
});
|
|
@@ -1099,7 +1131,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1099
1131
|
// pre-handshake.
|
|
1100
1132
|
//
|
|
1101
1133
|
// Placed FIRST — before bootstrap verify (which consumes the
|
|
1102
|
-
// single-use bootstrap token per the
|
|
1134
|
+
// single-use bootstrap token per the RenderChannelBootstrap
|
|
1103
1135
|
// docstring) and before session lookup/creation (DB work).
|
|
1104
1136
|
// Bootstrap iframes with a version mismatch must retry with a
|
|
1105
1137
|
// fresh bootstrap token; burning the token on a mismatch the
|
|
@@ -1108,21 +1140,21 @@ export function createSessionChannelServer(opts) {
|
|
|
1108
1140
|
if (Array.isArray(payload.supportedVersions) &&
|
|
1109
1141
|
payload.supportedVersions.length > 0 &&
|
|
1110
1142
|
!payload.supportedVersions.includes(PROTOCOL_SCHEMA_VERSION)) {
|
|
1111
|
-
const policy = opts.versionPolicy ??
|
|
1143
|
+
const policy = opts.versionPolicy ?? "reject";
|
|
1112
1144
|
sendError(ws, UPGRADE_REQUIRED, `Server speaks ${PROTOCOL_SCHEMA_VERSION}; client declared ` +
|
|
1113
|
-
`supportedVersions=[${payload.supportedVersions.join(
|
|
1145
|
+
`supportedVersions=[${payload.supportedVersions.join(", ")}].`, message.requestId, {
|
|
1114
1146
|
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
1115
1147
|
clientSupportedVersions: payload.supportedVersions,
|
|
1116
1148
|
policy,
|
|
1117
1149
|
});
|
|
1118
|
-
opts.logger.warn(
|
|
1119
|
-
|
|
1150
|
+
opts.logger.warn("session_channel_version_mismatch", {
|
|
1151
|
+
renderId: payload.renderId,
|
|
1120
1152
|
appId: payload.appId,
|
|
1121
1153
|
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
1122
1154
|
clientSupportedVersions: payload.supportedVersions,
|
|
1123
1155
|
policy,
|
|
1124
1156
|
});
|
|
1125
|
-
if (policy ===
|
|
1157
|
+
if (policy === "reject") {
|
|
1126
1158
|
try {
|
|
1127
1159
|
ws.close();
|
|
1128
1160
|
}
|
|
@@ -1132,22 +1164,22 @@ export function createSessionChannelServer(opts) {
|
|
|
1132
1164
|
}
|
|
1133
1165
|
return;
|
|
1134
1166
|
}
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
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.
|
|
1140
1172
|
let effectiveIdentity = identity;
|
|
1141
1173
|
let mintedSessionToken;
|
|
1142
|
-
if (typeof payload.
|
|
1174
|
+
if (typeof payload.wsToken === "string" && payload.wsToken.length > 0) {
|
|
1143
1175
|
if (!opts.bootstrap) {
|
|
1144
|
-
sendError(ws,
|
|
1176
|
+
sendError(ws, "BOOTSTRAP_NOT_SUPPORTED", "This server was not configured with ws-token-auth plumbing", message.requestId);
|
|
1145
1177
|
return;
|
|
1146
1178
|
}
|
|
1147
|
-
const verifyResult = opts.bootstrap.verify(payload.
|
|
1179
|
+
const verifyResult = opts.bootstrap.verify(payload.wsToken);
|
|
1148
1180
|
if (!verifyResult.ok) {
|
|
1149
|
-
opts.logger.warn(
|
|
1150
|
-
|
|
1181
|
+
opts.logger.warn("session_channel_bootstrap_rejected", {
|
|
1182
|
+
renderId: payload.renderId,
|
|
1151
1183
|
appId: payload.appId,
|
|
1152
1184
|
reason: verifyResult.reason,
|
|
1153
1185
|
});
|
|
@@ -1157,21 +1189,21 @@ export function createSessionChannelServer(opts) {
|
|
|
1157
1189
|
// (no refresh path); expired-but-signed envelopes emit the
|
|
1158
1190
|
// dedicated BOOTSTRAP_EXPIRED so the client knows to call
|
|
1159
1191
|
// `ggui_runtime_refresh_bootstrap`.
|
|
1160
|
-
if (verifyResult.reason ===
|
|
1161
|
-
sendError(ws,
|
|
1192
|
+
if (verifyResult.reason === "expired") {
|
|
1193
|
+
sendError(ws, "BOOTSTRAP_EXPIRED", "Bootstrap token expired — call ggui_runtime_refresh_bootstrap or re-handshake", message.requestId);
|
|
1162
1194
|
}
|
|
1163
1195
|
else {
|
|
1164
|
-
sendError(ws,
|
|
1196
|
+
sendError(ws, "BOOTSTRAP_INVALID", "Bootstrap token invalid (bad signature, malformed, or wrong kind)", message.requestId);
|
|
1165
1197
|
}
|
|
1166
1198
|
return;
|
|
1167
1199
|
}
|
|
1168
|
-
const bound = {
|
|
1169
|
-
if (bound.
|
|
1170
|
-
sendError(ws,
|
|
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);
|
|
1171
1203
|
return;
|
|
1172
1204
|
}
|
|
1173
1205
|
if (bound.appId !== payload.appId) {
|
|
1174
|
-
sendError(ws,
|
|
1206
|
+
sendError(ws, "BOOTSTRAP_APP_MISMATCH", `Bootstrap token is bound to app '${bound.appId}' but subscribe targets '${payload.appId}'`, message.requestId);
|
|
1175
1207
|
return;
|
|
1176
1208
|
}
|
|
1177
1209
|
// Synthesize a minimal AuthResult from the bootstrap claims.
|
|
@@ -1180,75 +1212,47 @@ export function createSessionChannelServer(opts) {
|
|
|
1180
1212
|
// citizen for the lifetime of this subscription.
|
|
1181
1213
|
effectiveIdentity = {
|
|
1182
1214
|
identity: {
|
|
1183
|
-
kind:
|
|
1184
|
-
userId: bound.
|
|
1215
|
+
kind: "user",
|
|
1216
|
+
userId: bound.renderId,
|
|
1185
1217
|
workspaceId: bound.appId,
|
|
1186
1218
|
roles: [],
|
|
1187
1219
|
},
|
|
1188
|
-
source:
|
|
1220
|
+
source: "apikey",
|
|
1189
1221
|
};
|
|
1190
1222
|
// Mint the reconnect credential now — before create/observe
|
|
1191
1223
|
// work — so a downstream failure doesn't leave the client with
|
|
1192
1224
|
// no way to resume.
|
|
1193
|
-
mintedSessionToken = opts.bootstrap.issueSessionToken(bound.
|
|
1194
|
-
opts.logger.info(
|
|
1195
|
-
|
|
1225
|
+
mintedSessionToken = opts.bootstrap.issueSessionToken(bound.renderId, bound.appId);
|
|
1226
|
+
opts.logger.info("session_channel_bootstrap_accepted", {
|
|
1227
|
+
renderId: bound.renderId,
|
|
1196
1228
|
appId: bound.appId,
|
|
1197
1229
|
});
|
|
1198
1230
|
}
|
|
1199
1231
|
// Dev-mode session provisioning: look up first; if not present,
|
|
1200
1232
|
// create with the client-provided id via the widened
|
|
1201
1233
|
// CreateSessionInput.id seam. Matches the hosted model's shape
|
|
1202
|
-
// (agent creates via
|
|
1234
|
+
// (agent creates via ggui_render → client subscribes) in a single
|
|
1203
1235
|
// step — production deployments tighten this by supplying an
|
|
1204
|
-
// AuthAdapter that mints session-scoped tokens on
|
|
1205
|
-
let session = await opts.
|
|
1236
|
+
// AuthAdapter that mints session-scoped tokens on render.
|
|
1237
|
+
let session = await opts.renderStore.get(payload.renderId);
|
|
1206
1238
|
if (session) {
|
|
1207
1239
|
if (session.appId !== payload.appId) {
|
|
1208
|
-
sendError(ws,
|
|
1240
|
+
sendError(ws, "APP_MISMATCH", `Session ${payload.renderId} belongs to a different app`, message.requestId);
|
|
1209
1241
|
return;
|
|
1210
1242
|
}
|
|
1211
1243
|
}
|
|
1212
1244
|
else {
|
|
1213
1245
|
try {
|
|
1214
|
-
session = await opts.
|
|
1215
|
-
id: payload.
|
|
1246
|
+
session = await opts.renderStore.create({
|
|
1247
|
+
id: payload.renderId,
|
|
1216
1248
|
appId: payload.appId,
|
|
1217
1249
|
});
|
|
1218
1250
|
}
|
|
1219
1251
|
catch (err) {
|
|
1220
|
-
sendError(ws,
|
|
1252
|
+
sendError(ws, "SESSION_CREATE_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
|
|
1221
1253
|
return;
|
|
1222
1254
|
}
|
|
1223
1255
|
}
|
|
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
1256
|
// Snapshot the outbound-stream cursor BEFORE registering the
|
|
1253
1257
|
// subscriber. Any concurrent producer that calls sendToSession
|
|
1254
1258
|
// between here and registration gets seq > snapshotSeq, so the
|
|
@@ -1257,45 +1261,39 @@ export function createSessionChannelServer(opts) {
|
|
|
1257
1261
|
// This is race-safe in single-threaded JS: the next few lines run
|
|
1258
1262
|
// synchronously up to `register(sub)`, and fan-out's per-subscriber
|
|
1259
1263
|
// `seq <= replayCompletedSeq` guard takes care of the window.
|
|
1260
|
-
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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;
|
|
1268
1272
|
// Reconnect: `fromSeq` present → replay per policy on declared
|
|
1269
1273
|
// AND reserved channels. Fresh subscribe: `fromSeq` absent →
|
|
1270
1274
|
// call with `fromSeq=0` + NO spec, so the buffer's spec-channel
|
|
1271
1275
|
// walk contributes nothing (preserving the "initial state comes
|
|
1272
|
-
// from ack.
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
|
|
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'
|
|
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"
|
|
1283
1281
|
? activeItem.streamSpec
|
|
1284
1282
|
: undefined;
|
|
1285
1283
|
const replay = payload.fromSeq !== undefined
|
|
1286
|
-
? await streamBuffer.replay(
|
|
1287
|
-
: await streamBuffer.replay(
|
|
1284
|
+
? await streamBuffer.replay(stored.id, payload.fromSeq, activeStreamSpec)
|
|
1285
|
+
: await streamBuffer.replay(stored.id, 0, undefined);
|
|
1288
1286
|
// Subscribe to the StreamFanout BEFORE constructing the Subscriber:
|
|
1289
1287
|
// the seam returns an AsyncIterable whose iterator we hand off; the
|
|
1290
1288
|
// pump loop in `register` consumes it. Eager registration on the
|
|
1291
1289
|
// seam side means any concurrent `streamFanout.publish` from this
|
|
1292
1290
|
// point onward queues into our iterator — paired with the
|
|
1293
1291
|
// replayCompletedSeq cursor below, that's race-free.
|
|
1294
|
-
const fanoutIter = streamFanout.subscribe(
|
|
1292
|
+
const fanoutIter = streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
|
|
1295
1293
|
const sub = {
|
|
1296
1294
|
ws,
|
|
1297
|
-
|
|
1298
|
-
appId:
|
|
1295
|
+
renderId: stored.id,
|
|
1296
|
+
appId: stored.appId,
|
|
1299
1297
|
identity: effectiveIdentity,
|
|
1300
1298
|
connectedAt: Date.now(),
|
|
1301
1299
|
replayCompletedSeq: snapshotSeq,
|
|
@@ -1306,9 +1304,9 @@ export function createSessionChannelServer(opts) {
|
|
|
1306
1304
|
channelSubs: new Map(),
|
|
1307
1305
|
};
|
|
1308
1306
|
register(sub);
|
|
1309
|
-
opts.logger.info(
|
|
1310
|
-
|
|
1311
|
-
appId:
|
|
1307
|
+
opts.logger.info("render_channel_subscribed", {
|
|
1308
|
+
renderId: stored.id,
|
|
1309
|
+
appId: stored.appId,
|
|
1312
1310
|
identityKind: effectiveIdentity.identity.kind,
|
|
1313
1311
|
fromSeq: payload.fromSeq,
|
|
1314
1312
|
snapshotSeq,
|
|
@@ -1317,9 +1315,9 @@ export function createSessionChannelServer(opts) {
|
|
|
1317
1315
|
bootstrap: mintedSessionToken !== undefined,
|
|
1318
1316
|
});
|
|
1319
1317
|
const ackPayload = {
|
|
1320
|
-
sequence:
|
|
1318
|
+
sequence: stored.eventSequence,
|
|
1321
1319
|
timestamp: Date.now(),
|
|
1322
|
-
|
|
1320
|
+
render: stored.render,
|
|
1323
1321
|
streamSeq: snapshotSeq,
|
|
1324
1322
|
// Advertise the server's protocol version on every successful
|
|
1325
1323
|
// subscribe ack (SPEC §11.2.2). Clients whose
|
|
@@ -1328,15 +1326,56 @@ export function createSessionChannelServer(opts) {
|
|
|
1328
1326
|
// the handshake ignore the field (legacy-pass-through).
|
|
1329
1327
|
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
1330
1328
|
...(replay?.truncated ? { replayTruncated: true } : {}),
|
|
1331
|
-
...(mintedSessionToken !== undefined
|
|
1332
|
-
? { sessionToken: mintedSessionToken }
|
|
1333
|
-
: {}),
|
|
1329
|
+
...(mintedSessionToken !== undefined ? { renderToken: mintedSessionToken } : {}),
|
|
1334
1330
|
};
|
|
1335
1331
|
send(ws, {
|
|
1336
|
-
type:
|
|
1332
|
+
type: "ack",
|
|
1337
1333
|
payload: ackPayload,
|
|
1338
1334
|
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
1339
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
|
+
}
|
|
1340
1379
|
// Send replay frames AFTER the ack. Ordering by `seq` ASC — the
|
|
1341
1380
|
// buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
|
|
1342
1381
|
// up to N replay `data` frames → live tail (seq > N). No explicit
|
|
@@ -1344,7 +1383,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1344
1383
|
// the single source of truth for ordering.
|
|
1345
1384
|
if (replay) {
|
|
1346
1385
|
for (const env of replay.envelopes) {
|
|
1347
|
-
send(ws, { type:
|
|
1386
|
+
send(ws, { type: "data", payload: env });
|
|
1348
1387
|
}
|
|
1349
1388
|
}
|
|
1350
1389
|
}
|
|
@@ -1355,21 +1394,21 @@ export function createSessionChannelServer(opts) {
|
|
|
1355
1394
|
message = JSON.parse(raw);
|
|
1356
1395
|
}
|
|
1357
1396
|
catch {
|
|
1358
|
-
sendError(ws,
|
|
1397
|
+
sendError(ws, "INVALID_JSON", "Message is not valid JSON");
|
|
1359
1398
|
return;
|
|
1360
1399
|
}
|
|
1361
|
-
if (!message || typeof message !==
|
|
1362
|
-
sendError(ws,
|
|
1400
|
+
if (!message || typeof message !== "object" || typeof message.type !== "string") {
|
|
1401
|
+
sendError(ws, "INVALID_MESSAGE", "Message is missing a `type` discriminator");
|
|
1363
1402
|
return;
|
|
1364
1403
|
}
|
|
1365
1404
|
switch (message.type) {
|
|
1366
|
-
case
|
|
1405
|
+
case "subscribe": {
|
|
1367
1406
|
// `subscribe` is the only message allowed before identity is
|
|
1368
1407
|
// bound to a session. Identity was already resolved at upgrade
|
|
1369
1408
|
// time; we just need to register the subscriber.
|
|
1370
1409
|
const identity = pendingIdentity.get(ws);
|
|
1371
1410
|
if (!identity) {
|
|
1372
|
-
sendError(ws,
|
|
1411
|
+
sendError(ws, "UNAUTHENTICATED", "No identity bound to this socket", message.requestId);
|
|
1373
1412
|
return;
|
|
1374
1413
|
}
|
|
1375
1414
|
// Cookie-scope enforcement: when the upgrade was authenticated
|
|
@@ -1378,12 +1417,12 @@ export function createSessionChannelServer(opts) {
|
|
|
1378
1417
|
// session A can't be used to open session B.
|
|
1379
1418
|
const cookieBound = pendingCookieBinding.get(ws);
|
|
1380
1419
|
if (cookieBound) {
|
|
1381
|
-
if (message.payload.
|
|
1382
|
-
sendError(ws,
|
|
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);
|
|
1383
1422
|
return;
|
|
1384
1423
|
}
|
|
1385
1424
|
if (message.payload.appId !== cookieBound.appId) {
|
|
1386
|
-
sendError(ws,
|
|
1425
|
+
sendError(ws, "DEVTOOL_COOKIE_APP_MISMATCH", `Embedded-ui cookie is bound to app '${cookieBound.appId}' but subscribe targets '${message.payload.appId}'`, message.requestId);
|
|
1387
1426
|
return;
|
|
1388
1427
|
}
|
|
1389
1428
|
}
|
|
@@ -1392,34 +1431,34 @@ export function createSessionChannelServer(opts) {
|
|
|
1392
1431
|
pendingCookieBinding.delete(ws);
|
|
1393
1432
|
return;
|
|
1394
1433
|
}
|
|
1395
|
-
case
|
|
1434
|
+
case "ping":
|
|
1396
1435
|
send(ws, {
|
|
1397
|
-
type:
|
|
1436
|
+
type: "pong",
|
|
1398
1437
|
payload: {},
|
|
1399
1438
|
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
1400
1439
|
});
|
|
1401
1440
|
return;
|
|
1402
|
-
case
|
|
1441
|
+
case "close":
|
|
1403
1442
|
// Explicit close from client — unregister + close the socket.
|
|
1404
1443
|
if (sub)
|
|
1405
1444
|
unregister(ws);
|
|
1406
|
-
ws.close(1000,
|
|
1445
|
+
ws.close(1000, "client_close");
|
|
1407
1446
|
return;
|
|
1408
|
-
case
|
|
1447
|
+
case "action":
|
|
1409
1448
|
if (!sub) {
|
|
1410
|
-
sendError(ws,
|
|
1449
|
+
sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'action'", message.requestId);
|
|
1411
1450
|
return;
|
|
1412
1451
|
}
|
|
1413
1452
|
await handleInboundAction(ws, sub, message);
|
|
1414
1453
|
return;
|
|
1415
|
-
case
|
|
1454
|
+
case "channel_subscribe":
|
|
1416
1455
|
if (!sub) {
|
|
1417
|
-
sendError(ws,
|
|
1456
|
+
sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'channel_subscribe'", message.requestId);
|
|
1418
1457
|
return;
|
|
1419
1458
|
}
|
|
1420
1459
|
await handleChannelSubscribe(ws, sub, message);
|
|
1421
1460
|
return;
|
|
1422
|
-
case
|
|
1461
|
+
case "channel_unsubscribe":
|
|
1423
1462
|
if (!sub) {
|
|
1424
1463
|
// No subscriber → nothing was subscribed → no-op silently.
|
|
1425
1464
|
// Returning an error would leak "is this socket subscribed"
|
|
@@ -1428,7 +1467,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1428
1467
|
}
|
|
1429
1468
|
handleChannelUnsubscribe(ws, sub, message);
|
|
1430
1469
|
return;
|
|
1431
|
-
case
|
|
1470
|
+
case "host_context_observed":
|
|
1432
1471
|
// The iframe-runtime echoes its captured `McpUiHostContext`
|
|
1433
1472
|
// after `ui/initialize` resolves and on every
|
|
1434
1473
|
// `ui/notifications/host-context-changed` notification. Persist
|
|
@@ -1438,44 +1477,25 @@ export function createSessionChannelServer(opts) {
|
|
|
1438
1477
|
if (!checkSubscriberTenancy(ws, sub, message.payload, message.type, message.requestId)) {
|
|
1439
1478
|
return;
|
|
1440
1479
|
}
|
|
1441
|
-
await applySessionPatch(sub.
|
|
1442
|
-
|
|
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,
|
|
1480
|
+
await applySessionPatch(sub.renderId, sub.appId, message.type, {
|
|
1481
|
+
hostContext: message.payload.hostContext,
|
|
1459
1482
|
lastActivityAt: Date.now(),
|
|
1460
1483
|
});
|
|
1461
1484
|
return;
|
|
1462
|
-
case
|
|
1463
|
-
case 'get_stack':
|
|
1464
|
-
case 'generate':
|
|
1465
|
-
case 'feedback':
|
|
1485
|
+
case "feedback":
|
|
1466
1486
|
// Require an active subscription for operational messages.
|
|
1467
1487
|
if (!sub) {
|
|
1468
|
-
sendError(ws,
|
|
1488
|
+
sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${message.type}'`, message.requestId);
|
|
1469
1489
|
return;
|
|
1470
1490
|
}
|
|
1471
1491
|
// These OSS channel handlers land incrementally once the
|
|
1472
1492
|
// matching shared handlers exist in @ggui-ai/mcp-server-handlers.
|
|
1473
1493
|
// For now the ingress point is documented but rejected with a
|
|
1474
1494
|
// clear code so clients don't assume silent success.
|
|
1475
|
-
sendError(ws,
|
|
1495
|
+
sendError(ws, "NOT_IMPLEMENTED", `'${message.type}' not yet handled on the OSS channel server`, message.requestId);
|
|
1476
1496
|
return;
|
|
1477
1497
|
default:
|
|
1478
|
-
sendError(ws,
|
|
1498
|
+
sendError(ws, "UNSUPPORTED_MESSAGE", `Unsupported message type: ${String(message.type)}`, message.requestId);
|
|
1479
1499
|
}
|
|
1480
1500
|
}
|
|
1481
1501
|
/**
|
|
@@ -1487,13 +1507,13 @@ export function createSessionChannelServer(opts) {
|
|
|
1487
1507
|
const pendingIdentity = new WeakMap();
|
|
1488
1508
|
/**
|
|
1489
1509
|
* Embedded-ui cookie binding established at upgrade. When present,
|
|
1490
|
-
* `handleSubscribe` enforces `subscribe.
|
|
1510
|
+
* `handleSubscribe` enforces `subscribe.renderId === bound.renderId`
|
|
1491
1511
|
* so a valid cookie can't be used to open a session it wasn't
|
|
1492
1512
|
* issued for. Parallel to {@link pendingIdentity} — same lifetime,
|
|
1493
1513
|
* same WeakMap rationale.
|
|
1494
1514
|
*/
|
|
1495
1515
|
const pendingCookieBinding = new WeakMap();
|
|
1496
|
-
wss.on(
|
|
1516
|
+
wss.on("connection", (ws, req) => {
|
|
1497
1517
|
// Bind the resolved identity from the upgrade phase. It was
|
|
1498
1518
|
// attached to the request object in handleUpgrade.
|
|
1499
1519
|
const identity = req.__gguiIdentity;
|
|
@@ -1503,22 +1523,22 @@ export function createSessionChannelServer(opts) {
|
|
|
1503
1523
|
const cookieBound = req.__gguiCookieBound;
|
|
1504
1524
|
if (cookieBound)
|
|
1505
1525
|
pendingCookieBinding.set(ws, cookieBound);
|
|
1506
|
-
ws.on(
|
|
1526
|
+
ws.on("message", (raw) => {
|
|
1507
1527
|
// `ws.on('message')` delivers Buffer/ArrayBuffer/Buffer[] depending
|
|
1508
1528
|
// on frame type; normalize to string.
|
|
1509
|
-
const text = typeof raw ===
|
|
1529
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
1510
1530
|
onMessage(ws, text).catch((err) => {
|
|
1511
|
-
opts.logger.error(
|
|
1531
|
+
opts.logger.error("session_channel_message_failed", {
|
|
1512
1532
|
error: String(err),
|
|
1513
1533
|
});
|
|
1514
1534
|
});
|
|
1515
1535
|
});
|
|
1516
|
-
ws.on(
|
|
1536
|
+
ws.on("close", () => {
|
|
1517
1537
|
unregister(ws);
|
|
1518
1538
|
pendingIdentity.delete(ws);
|
|
1519
1539
|
});
|
|
1520
|
-
ws.on(
|
|
1521
|
-
opts.logger.warn(
|
|
1540
|
+
ws.on("error", (err) => {
|
|
1541
|
+
opts.logger.warn("session_channel_socket_error", { error: String(err) });
|
|
1522
1542
|
});
|
|
1523
1543
|
});
|
|
1524
1544
|
return {
|
|
@@ -1532,27 +1552,26 @@ export function createSessionChannelServer(opts) {
|
|
|
1532
1552
|
req.__gguiIdentity = identity;
|
|
1533
1553
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1534
1554
|
// Expose `upgradeReq` for the connection handler.
|
|
1535
|
-
wss.emit(
|
|
1555
|
+
wss.emit("connection", ws, req);
|
|
1536
1556
|
});
|
|
1537
1557
|
})
|
|
1538
1558
|
.catch((err) => {
|
|
1539
1559
|
if (err instanceof UnauthenticatedError) {
|
|
1540
|
-
opts.logger.warn(
|
|
1560
|
+
opts.logger.warn("session_channel_auth_failed", {
|
|
1541
1561
|
reason: err.message,
|
|
1542
1562
|
});
|
|
1543
|
-
socket.write(
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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: " +
|
|
1547
1567
|
err.message +
|
|
1548
|
-
|
|
1568
|
+
"\r\n");
|
|
1549
1569
|
}
|
|
1550
1570
|
else {
|
|
1551
|
-
opts.logger.error(
|
|
1571
|
+
opts.logger.error("session_channel_upgrade_failed", {
|
|
1552
1572
|
error: String(err),
|
|
1553
1573
|
});
|
|
1554
|
-
socket.write(
|
|
1555
|
-
'Connection: close\r\n\r\n');
|
|
1574
|
+
socket.write("HTTP/1.1 500 Internal Server Error\r\n" + "Connection: close\r\n\r\n");
|
|
1556
1575
|
}
|
|
1557
1576
|
socket.destroy();
|
|
1558
1577
|
});
|
|
@@ -1560,60 +1579,48 @@ export function createSessionChannelServer(opts) {
|
|
|
1560
1579
|
async sendToSession(delivery) {
|
|
1561
1580
|
// Outbound fan-out enforcement (defense-in-depth parity with
|
|
1562
1581
|
// hosted `handle-data.ts`). Re-validates the delivery's payload
|
|
1563
|
-
// against the
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
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
|
|
1566
1585
|
// ContractViolationError{tool:'ggui_emit'} on violation;
|
|
1567
1586
|
// caller decides what to do (log, rethrow, wrap).
|
|
1568
|
-
const
|
|
1569
|
-
const
|
|
1570
|
-
const
|
|
1571
|
-
? session.stack[activeIndex]
|
|
1572
|
-
: undefined;
|
|
1573
|
-
const streamSpec = activeEntry !== undefined &&
|
|
1574
|
-
activeEntry.type !== 'mcpApps' &&
|
|
1575
|
-
activeEntry.type !== 'system'
|
|
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"
|
|
1576
1590
|
? activeEntry.streamSpec
|
|
1577
1591
|
: undefined;
|
|
1578
1592
|
assertStreamContract(streamSpec, delivery.channel, delivery.payload, opts.extraReservedValidators);
|
|
1579
1593
|
return fanOut(delivery, streamSpec);
|
|
1580
1594
|
},
|
|
1581
|
-
|
|
1595
|
+
notifyRenderPush(renderId, render, matchType) {
|
|
1582
1596
|
// Best-effort fan-out to every live subscriber bound to this
|
|
1583
|
-
//
|
|
1584
|
-
// `
|
|
1585
|
-
// subscribers rely on `ack.
|
|
1586
|
-
// NOT routed through StreamFanout either — `type: '
|
|
1587
|
-
// distinct WebSocket message type
|
|
1588
|
-
//
|
|
1589
|
-
|
|
1590
|
-
const payload = matchType !== undefined ? { stackItem, matchType } : { stackItem };
|
|
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 };
|
|
1591
1604
|
for (const sub of wsSubscribers) {
|
|
1592
|
-
if (sub.
|
|
1605
|
+
if (sub.renderId !== renderId)
|
|
1593
1606
|
continue;
|
|
1594
|
-
|
|
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 });
|
|
1607
|
+
send(sub.ws, { type: "render", payload });
|
|
1599
1608
|
}
|
|
1600
1609
|
},
|
|
1601
|
-
async primeStreams(
|
|
1610
|
+
async primeStreams(renderId, render) {
|
|
1602
1611
|
const router = opts.wiredActionRouter;
|
|
1603
|
-
const streamSpec =
|
|
1612
|
+
const streamSpec = "streamSpec" in render ? render.streamSpec : undefined;
|
|
1604
1613
|
if (!router || !streamSpec)
|
|
1605
1614
|
return;
|
|
1606
1615
|
const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
|
|
1607
1616
|
// Build the same wired-action ctx the dispatcher uses.
|
|
1608
|
-
// Prime-time invocations reuse the seam so a refresh
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
// item's id; closures lock to the caller-supplied sessionId.
|
|
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.
|
|
1612
1620
|
const wiredCtx = {
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
void sendPropsUpdateImpl(sessionId, targetStackItemId, props);
|
|
1621
|
+
renderId,
|
|
1622
|
+
sendPropsUpdate(props) {
|
|
1623
|
+
void sendPropsUpdateImpl(renderId, props);
|
|
1617
1624
|
},
|
|
1618
1625
|
};
|
|
1619
1626
|
for (const [channelName, channelEntry] of Object.entries(streamSpec)) {
|
|
@@ -1621,8 +1628,8 @@ export function createSessionChannelServer(opts) {
|
|
|
1621
1628
|
if (!refreshTool)
|
|
1622
1629
|
continue;
|
|
1623
1630
|
if (!router.has(refreshTool)) {
|
|
1624
|
-
opts.logger.warn(
|
|
1625
|
-
|
|
1631
|
+
opts.logger.warn("render_channel_prime_tool_not_found", {
|
|
1632
|
+
renderId,
|
|
1626
1633
|
toolName: refreshTool,
|
|
1627
1634
|
channel: channelName,
|
|
1628
1635
|
});
|
|
@@ -1633,8 +1640,8 @@ export function createSessionChannelServer(opts) {
|
|
|
1633
1640
|
output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
|
|
1634
1641
|
}
|
|
1635
1642
|
catch (err) {
|
|
1636
|
-
opts.logger.warn(
|
|
1637
|
-
|
|
1643
|
+
opts.logger.warn("render_channel_prime_tool_failed", {
|
|
1644
|
+
renderId,
|
|
1638
1645
|
toolName: refreshTool,
|
|
1639
1646
|
channel: channelName,
|
|
1640
1647
|
error: String(err),
|
|
@@ -1645,8 +1652,8 @@ export function createSessionChannelServer(opts) {
|
|
|
1645
1652
|
assertStreamContract(streamSpec, channelName, output, opts.extraReservedValidators);
|
|
1646
1653
|
}
|
|
1647
1654
|
catch (err) {
|
|
1648
|
-
opts.logger.warn(
|
|
1649
|
-
|
|
1655
|
+
opts.logger.warn("render_channel_prime_schema_violation", {
|
|
1656
|
+
renderId,
|
|
1650
1657
|
toolName: refreshTool,
|
|
1651
1658
|
channel: channelName,
|
|
1652
1659
|
error: String(err),
|
|
@@ -1655,15 +1662,15 @@ export function createSessionChannelServer(opts) {
|
|
|
1655
1662
|
}
|
|
1656
1663
|
try {
|
|
1657
1664
|
await fanOut({
|
|
1658
|
-
|
|
1665
|
+
renderId,
|
|
1659
1666
|
channel: channelName,
|
|
1660
|
-
mode: channelEntry?.mode ??
|
|
1667
|
+
mode: channelEntry?.mode ?? "append",
|
|
1661
1668
|
payload: output,
|
|
1662
1669
|
}, streamSpec);
|
|
1663
1670
|
}
|
|
1664
1671
|
catch (err) {
|
|
1665
|
-
opts.logger.error(
|
|
1666
|
-
|
|
1672
|
+
opts.logger.error("render_channel_prime_emit_failed", {
|
|
1673
|
+
renderId,
|
|
1667
1674
|
toolName: refreshTool,
|
|
1668
1675
|
channel: channelName,
|
|
1669
1676
|
error: String(err),
|
|
@@ -1671,41 +1678,53 @@ export function createSessionChannelServer(opts) {
|
|
|
1671
1678
|
}
|
|
1672
1679
|
}
|
|
1673
1680
|
},
|
|
1674
|
-
sendPropsUpdate(
|
|
1681
|
+
sendPropsUpdate(renderId, props) {
|
|
1675
1682
|
// Public entry point — delegates to the closure-level impl that
|
|
1676
1683
|
// the wired-action dispatcher's `WiredActionContext.sendPropsUpdate`
|
|
1677
1684
|
// also calls. Returns the impl's promise so the caller can await
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
return sendPropsUpdateImpl(
|
|
1685
|
+
// store-lookup completion if desired (the wiredCtx call site
|
|
1686
|
+
// fire-and-forgets via `void`).
|
|
1687
|
+
return sendPropsUpdateImpl(renderId, props);
|
|
1681
1688
|
},
|
|
1682
|
-
sendDrainAck({
|
|
1689
|
+
sendDrainAck({ renderId, appId, eventId, drainedAt }) {
|
|
1683
1690
|
// Server-side fan-out for the action-drain ack.
|
|
1684
|
-
// Filter the flat WS-subscriber set by
|
|
1691
|
+
// Filter the flat WS-subscriber set by renderId (same posture
|
|
1685
1692
|
// as `sendPropsUpdate`). No persistence; subscribers that
|
|
1686
1693
|
// missed the frame fall back to their 10s claim timer, which
|
|
1687
|
-
// the atomic pop resolves cleanly.
|
|
1688
|
-
// skips closed sockets and absorbs per-subscriber send
|
|
1689
|
-
// failures.
|
|
1694
|
+
// the atomic pop resolves cleanly.
|
|
1690
1695
|
for (const sub of wsSubscribers) {
|
|
1691
|
-
if (sub.
|
|
1696
|
+
if (sub.renderId !== renderId)
|
|
1692
1697
|
continue;
|
|
1693
1698
|
send(sub.ws, {
|
|
1694
|
-
type:
|
|
1695
|
-
payload: {
|
|
1699
|
+
type: "drain_ack",
|
|
1700
|
+
payload: { renderId, appId, eventId, drainedAt },
|
|
1696
1701
|
});
|
|
1697
1702
|
}
|
|
1698
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
|
+
},
|
|
1699
1718
|
get subscriberCount() {
|
|
1700
1719
|
return wsSubscribers.size;
|
|
1701
1720
|
},
|
|
1702
1721
|
get sessionCount() {
|
|
1703
1722
|
// Distinct session count across live WS subscribers. With
|
|
1704
|
-
// multi-tab sessions, two subscribers may share a
|
|
1723
|
+
// multi-tab sessions, two subscribers may share a renderId —
|
|
1705
1724
|
// dedupe before counting.
|
|
1706
1725
|
const sessions = new Set();
|
|
1707
1726
|
for (const sub of wsSubscribers)
|
|
1708
|
-
sessions.add(sub.
|
|
1727
|
+
sessions.add(sub.renderId);
|
|
1709
1728
|
return sessions.size;
|
|
1710
1729
|
},
|
|
1711
1730
|
async close() {
|
|
@@ -1726,9 +1745,9 @@ export function createSessionChannelServer(opts) {
|
|
|
1726
1745
|
// case and the wrong signal for client reconnect logic.
|
|
1727
1746
|
const sessions = new Set();
|
|
1728
1747
|
for (const sub of wsSubscribers) {
|
|
1729
|
-
sessions.add(sub.
|
|
1748
|
+
sessions.add(sub.renderId);
|
|
1730
1749
|
try {
|
|
1731
|
-
sub.ws.close(1012,
|
|
1750
|
+
sub.ws.close(1012, "service_restart");
|
|
1732
1751
|
}
|
|
1733
1752
|
catch {
|
|
1734
1753
|
/* best-effort */
|
|
@@ -1741,7 +1760,7 @@ export function createSessionChannelServer(opts) {
|
|
|
1741
1760
|
// unregister race). For InProcessStreamFanout this is a no-op
|
|
1742
1761
|
// when there are no subscribers; for hosted bindings it ensures
|
|
1743
1762
|
// the per-session pub/sub channel teardown fires.
|
|
1744
|
-
await Promise.all(Array.from(sessions, (
|
|
1763
|
+
await Promise.all(Array.from(sessions, (renderId) => streamFanout.close(renderId).catch(() => {
|
|
1745
1764
|
/* best-effort */
|
|
1746
1765
|
})));
|
|
1747
1766
|
await new Promise((resolve) => {
|