@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.
- package/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- 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
|
+
}
|