@robota-sdk/agent-transport-ws 3.0.0-beta.100

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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Self-contained WS transport implementing IConfigurableTransport.
3
+ * Owns the WebSocket server lifecycle (ws package), started/stopped via the transport registry.
4
+ */
5
+
6
+ import { createServer, type IncomingMessage, type Server } from 'node:http';
7
+
8
+ import { createSessionMessageHandler } from '@robota-sdk/agent-transport';
9
+ import { WebSocketServer } from 'ws';
10
+
11
+ import { PayloadChannelRegistry } from './payload-channels.js';
12
+ import {
13
+ hostAllowed,
14
+ originAllowed,
15
+ presentedToken,
16
+ resolveWsAdmission,
17
+ tokenMatches,
18
+ } from './ws-connection-guards.js';
19
+ import { toBytes } from './ws-message-data.js';
20
+ import { WsSessionDelivery } from './ws-session-delivery.js';
21
+ import {
22
+ DEFAULT_MAX_RETRIES,
23
+ DEFAULT_PORT,
24
+ configuredWsHandlerOptions,
25
+ transportLifecycleError,
26
+ validTransportOptions,
27
+ } from './ws-transport-config.js';
28
+
29
+ import type { IWsTransportConfig } from './ws-transport-config.js';
30
+ import type { TUniversalValue } from '@robota-sdk/agent-core';
31
+ import type { ISessionBinder, ISessionBinding } from '@robota-sdk/agent-interface-session';
32
+ import type {
33
+ IChannelDescriptor,
34
+ IConfigurableTransport,
35
+ IPayloadChannel,
36
+ IPayloadChannelHost,
37
+ TChannelEventMap,
38
+ } from '@robota-sdk/agent-interface-transport';
39
+ import type { IProtocolSession } from '@robota-sdk/agent-transport';
40
+ import type { RawData, WebSocket } from 'ws';
41
+
42
+ /**
43
+ * RUNTIME-13: forced-terminate deadline for `stop()`. `WebSocketServer.close()` fires its callback only after
44
+ * every client socket is gone, so a still-connected client would hang `stop()` forever. We send a close frame
45
+ * (1001 "going away") to each client, then `terminate()` any socket still open at this deadline — the RFC 6455
46
+ * + `ws` + drain-then-force convention (a WS close handshake completes in well under a second; 5s is generous).
47
+ */
48
+ const WS_STOP_TERMINATE_DEADLINE_MS = 5000;
49
+
50
+ /**
51
+ * TRANS-001: the WS transport is a payload-agnostic CARRIER that routes by WebSocket frame opcode —
52
+ * TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the
53
+ * consumer-declared channels. The two profiles share one connection and never constrain each other.
54
+ */
55
+ export class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {
56
+ readonly name = 'ws';
57
+ readonly lifecycle = Object.freeze({ kind: 'service' as const });
58
+ readonly defaultEnabled = true;
59
+ readonly optionsSchema = {
60
+ port: { type: 'number', description: 'WebSocket server port', default: DEFAULT_PORT },
61
+ maxRetries: {
62
+ type: 'number',
63
+ description: 'Port retry attempts when port is occupied',
64
+ default: DEFAULT_MAX_RETRIES,
65
+ },
66
+ };
67
+
68
+ private session: IProtocolSession | null = null;
69
+ private stopFn: (() => Promise<void>) | null = null;
70
+ private state: 'detached' | 'attached' | 'starting' | 'ready' | 'stopping' = 'detached';
71
+ private startOperation: Promise<{ stop: () => Promise<void>; port: number }> | undefined;
72
+ private startCancelled = false;
73
+ private port: number;
74
+ private maxRetries: number;
75
+ private readonly token?: string;
76
+ private readonly allowedHosts: ReadonlySet<string>;
77
+ private readonly allowedOrigins: ReadonlySet<string>;
78
+ private readonly handlerOptions: ReturnType<typeof configuredWsHandlerOptions>;
79
+ private readonly sessionBinder: ISessionBinder<IProtocolSession> | undefined;
80
+ private readonly channels = new PayloadChannelRegistry();
81
+ private resolvedPort?: number;
82
+
83
+ constructor(config: IWsTransportConfig = {}) {
84
+ this.port = config.port ?? DEFAULT_PORT;
85
+ this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
86
+ // SEC-001/SEC-008: secure by default, decided by the shared seam rather than a copy here — see
87
+ // SPEC § Transport Admission. A failed mint throws out of the constructor (fail-closed).
88
+ const admission = resolveWsAdmission(config);
89
+ if (admission.token !== null) this.token = admission.token;
90
+ this.allowedHosts = new Set(config.allowedHosts ?? []);
91
+ this.allowedOrigins = new Set(config.allowedOrigins ?? []);
92
+ this.handlerOptions = configuredWsHandlerOptions(config);
93
+ this.sessionBinder = config.sessionBinder;
94
+ }
95
+
96
+ attach(session: IProtocolSession): void {
97
+ this.session = session;
98
+ this.state = 'attached';
99
+ }
100
+
101
+ /**
102
+ * TRANS-001: declare an app-level channel carried on this transport's connections. The channel's
103
+ * events and opaque binary frames ride alongside the text-agent protocol on the SAME socket — the
104
+ * transport never inspects the payloads. Register before or after `start()`; a channel opened
105
+ * later is served by every already-connected client.
106
+ */
107
+ registerChannel<TEvents extends TChannelEventMap>(
108
+ descriptor: IChannelDescriptor<TEvents>,
109
+ ): IPayloadChannel<TEvents> {
110
+ return this.channels.registerChannel(descriptor);
111
+ }
112
+
113
+ /** Actually-bound port after start; undefined before start. */
114
+ get boundPort(): number | undefined {
115
+ return this.resolvedPort;
116
+ }
117
+
118
+ /**
119
+ * SEC-001: the resolved auth token (explicit or auto-minted), or `undefined` in the discouraged `open`
120
+ * mode. The surface reads this to deliver the token to the co-located client (a `0600` connection file /
121
+ * the served monitor's injected `ws-url`). Never logged by the transport itself.
122
+ */
123
+ get resolvedToken(): string | undefined {
124
+ return this.token;
125
+ }
126
+
127
+ async start(): Promise<void> {
128
+ if (!this.session) throw transportLifecycleError(this.name, 'not-attached');
129
+ if (this.state === 'starting' || this.state === 'ready' || this.state === 'stopping') {
130
+ throw transportLifecycleError(this.name, 'already-started');
131
+ }
132
+ this.state = 'starting';
133
+ this.startCancelled = false;
134
+ const operation = this.bindWithRetry(this.session, this.port, this.maxRetries);
135
+ this.startOperation = operation;
136
+ let handle: Awaited<typeof operation>;
137
+ try {
138
+ handle = await operation;
139
+ } catch (error) {
140
+ if (this.state === 'starting' && this.startOperation === operation) {
141
+ this.startOperation = undefined;
142
+ this.state = this.session ? 'attached' : 'detached';
143
+ }
144
+ throw error;
145
+ }
146
+ if (this.startCancelled || this.startOperation !== operation) {
147
+ await handle.stop();
148
+ this.state = this.session ? 'attached' : 'detached';
149
+ throw new Error('WsTransport startup was stopped.');
150
+ }
151
+ this.stopFn = handle.stop;
152
+ this.resolvedPort = handle.port;
153
+ this.startOperation = undefined;
154
+ this.state = 'ready';
155
+ }
156
+
157
+ async stop(): Promise<void> {
158
+ if (this.state === 'starting') {
159
+ this.state = 'stopping';
160
+ this.startCancelled = true;
161
+ try {
162
+ await this.startOperation;
163
+ } catch {
164
+ // The start caller owns bind failure; stop still clears lifecycle state.
165
+ }
166
+ }
167
+ await this.stopFn?.();
168
+ this.stopFn = null;
169
+ this.startOperation = undefined;
170
+ this.resolvedPort = undefined;
171
+ this.session = null;
172
+ this.state = 'detached';
173
+ }
174
+
175
+ /** TRANS-002 (issue #2480): persisted `port`/`maxRetries` reach the server through here, before start. */
176
+ configure(options: Record<string, TUniversalValue>): void {
177
+ if (this.state !== 'detached' && this.state !== 'attached') {
178
+ throw transportLifecycleError(this.name, 'already-started');
179
+ }
180
+ if (!this.validateOptions(options)) {
181
+ throw new TypeError('WsTransport options are invalid (port 1-65535, maxRetries >= 0).');
182
+ }
183
+ if (typeof options['port'] === 'number') this.port = options['port'];
184
+ if (typeof options['maxRetries'] === 'number') this.maxRetries = options['maxRetries'];
185
+ }
186
+
187
+ validateOptions(options: Record<string, TUniversalValue>): boolean {
188
+ return validTransportOptions(options);
189
+ }
190
+
191
+ /**
192
+ * The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host
193
+ * would not bind it (shutting down, say): the socket is then closed before any session data.
194
+ */
195
+ private bindConnection(
196
+ ws: WebSocket,
197
+ ): { binding: ISessionBinding<IProtocolSession> | undefined } | null {
198
+ if (!this.sessionBinder) return { binding: undefined };
199
+ try {
200
+ return { binding: this.sessionBinder.bind('drive') };
201
+ } catch {
202
+ ws.close(1013, 'session unavailable');
203
+ return null;
204
+ }
205
+ }
206
+
207
+ private bindWithRetry(
208
+ session: IProtocolSession,
209
+ port: number,
210
+ retriesLeft: number,
211
+ ): Promise<{ stop: () => Promise<void>; port: number }> {
212
+ return this.tryBind(session, port).catch((err: NodeJS.ErrnoException) => {
213
+ if (err.code === 'EADDRINUSE' && retriesLeft > 0)
214
+ return this.bindWithRetry(session, port + 1, retriesLeft - 1);
215
+ throw err;
216
+ });
217
+ }
218
+
219
+ private tryBind(
220
+ session: IProtocolSession,
221
+ port: number,
222
+ ): Promise<{ stop: () => Promise<void>; port: number }> {
223
+ return new Promise((resolve, reject) => {
224
+ const httpServer: Server = createServer((_, res) => {
225
+ res.writeHead(400).end('WebSocket endpoint');
226
+ });
227
+
228
+ httpServer.on('error', (err: NodeJS.ErrnoException) => {
229
+ httpServer.close();
230
+ reject(err);
231
+ });
232
+
233
+ const expectedToken = this.token;
234
+ const allowedHosts = this.allowedHosts;
235
+ const allowedOrigins = this.allowedOrigins;
236
+ const channels = this.channels;
237
+ httpServer.listen(port, '127.0.0.1', () => {
238
+ const wss = new WebSocketServer({
239
+ server: httpServer,
240
+ // Reject disallowed Host/Origin before the protocol upgrade or any session data.
241
+ verifyClient: (
242
+ info: { origin: string; secure: boolean; req: IncomingMessage },
243
+ cb: (res: boolean, code?: number, message?: string) => void,
244
+ ) => {
245
+ if (!hostAllowed(info.req, allowedHosts)) {
246
+ cb(false, 403, 'Forbidden host');
247
+ return;
248
+ }
249
+ if (!originAllowed(info.req, allowedOrigins)) {
250
+ cb(false, 403, 'Forbidden origin');
251
+ return;
252
+ }
253
+ cb(true);
254
+ },
255
+ });
256
+
257
+ wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
258
+ // Reject unauthenticated peers before sending history or accepting prompt responses.
259
+ if (expectedToken !== undefined && !tokenMatches(expectedToken, presentedToken(req))) {
260
+ ws.close(1008, 'unauthorized');
261
+ return;
262
+ }
263
+
264
+ // #3189: with a binder, this connection gets its own session and directory, so its
265
+ // switch (and the `session_switched` that follows) stays on this connection. Every WS
266
+ // connection drives: this carrier has no observe role.
267
+ const bound = this.bindConnection(ws);
268
+ if (bound === null) return;
269
+ const { binding } = bound;
270
+ const connectionSession = binding?.session ?? session;
271
+ const delivery = new WsSessionDelivery(ws);
272
+ const handler = createSessionMessageHandler({
273
+ session: connectionSession,
274
+ deliver: delivery.deliver,
275
+ ...this.handlerOptions,
276
+ ...(binding ? { sessionDirectory: binding.directory } : {}),
277
+ });
278
+ delivery.bindProtocolCleanup(() => {
279
+ handler.cleanup();
280
+ binding?.release();
281
+ });
282
+
283
+ delivery.bindSinkDetach(
284
+ // ARCH-030: through the connection's own delivery, so payload frames share the text
285
+ // protocol's backpressure budget and its close policy. The bare `readyState` check plus
286
+ // `ws.send` that stood here was the last outbound path on this socket outside the
287
+ // boundary.
288
+ channels.addSink((frame: Uint8Array) => delivery.deliverBinary(frame)),
289
+ );
290
+
291
+ // Route by frame opcode: TEXT → the text-agent protocol profile, BINARY → the
292
+ // payload-agnostic channels. An unroutable channel frame is answered with an explicit
293
+ // `protocol_error` rather than silently dropped.
294
+ ws.on('message', (data: RawData, isBinary: boolean) => {
295
+ if (!isBinary) {
296
+ handler.onMessage(String(data));
297
+ return;
298
+ }
299
+ const result = channels.receive(toBytes(data));
300
+ if (!result.ok) delivery.deliver({ type: 'protocol_error', message: result.error });
301
+ });
302
+ ws.on('close', delivery.close);
303
+ ws.on('error', delivery.close);
304
+
305
+ delivery.deliver({
306
+ type: 'messages',
307
+ messages: connectionSession.getMessages(),
308
+ ...(this.handlerOptions.driverId ? { driverId: this.handlerOptions.driverId } : {}),
309
+ });
310
+ delivery.deliver({
311
+ type: 'execution_workspace_event',
312
+ snapshot: connectionSession.getExecutionWorkspaceSnapshot(),
313
+ });
314
+ });
315
+
316
+ resolve({
317
+ port,
318
+ stop: () =>
319
+ new Promise<void>((res) => {
320
+ // RUNTIME-13: graceful-then-forced shutdown. Send a close frame to each client so well-behaved
321
+ // peers close cleanly; terminate any socket still open at the deadline so `wss.close()`'s
322
+ // all-clients-gone callback can never hang. `verifyClient`/token gates are connection-time and
323
+ // untouched here (SEC-001 preserved).
324
+ for (const client of wss.clients) client.close(1001, 'server shutting down');
325
+ const deadline = setTimeout(() => {
326
+ for (const client of wss.clients) client.terminate();
327
+ }, WS_STOP_TERMINATE_DEADLINE_MS);
328
+ wss.close(() => {
329
+ clearTimeout(deadline);
330
+ httpServer.close(() => res());
331
+ });
332
+ }),
333
+ });
334
+ });
335
+ });
336
+ }
337
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * ITransportAdapter implementation for WebSocket transport.
3
+ *
4
+ * Wraps createSessionMessageHandler into the unified ITransportAdapter interface.
5
+ * After start(), the consumer must wire onMessage to their WebSocket.
6
+ */
7
+
8
+ import { createOutboundDelivery, createSessionMessageHandler } from '@robota-sdk/agent-transport';
9
+
10
+ import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
11
+ import type { TDriverId } from '@robota-sdk/agent-interface-session';
12
+ import type {
13
+ ITransportAdapter,
14
+ ITransportLifecycleError,
15
+ } from '@robota-sdk/agent-interface-transport';
16
+ import type {
17
+ IProtocolSession,
18
+ ISessionMessageHandlerOptions,
19
+ TServerMessage,
20
+ } from '@robota-sdk/agent-transport';
21
+
22
+ export interface IWsTransportOptions {
23
+ /** Send a JSON message to the connected WebSocket client. */
24
+ send: (message: TServerMessage) => void;
25
+ /** Owning socket lifecycle callback for outbound session-event delivery failures. */
26
+ onDeliveryError?: (error: Error, event: string) => void;
27
+ personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
28
+ usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
29
+ storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
30
+ sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
31
+ settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
32
+ driverId?: TDriverId;
33
+ surface?: TUsageSurface;
34
+ }
35
+
36
+ export interface IWsTransport extends ITransportAdapter<IProtocolSession> {
37
+ onMessage: ((data: string) => void) | null;
38
+ }
39
+
40
+ export function createWsTransport(options: IWsTransportOptions): IWsTransport {
41
+ let session: IProtocolSession | null = null;
42
+ let cleanup: (() => void) | null = null;
43
+ const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
44
+ Object.assign(new Error(`WebSocket transport ${code}.`), {
45
+ name: 'TransportLifecycleError' as const,
46
+ code,
47
+ transportName: 'ws',
48
+ });
49
+
50
+ const transport: IWsTransport = {
51
+ name: 'ws',
52
+ lifecycle: Object.freeze({ kind: 'service' }),
53
+ onMessage: null,
54
+ attach(s: IProtocolSession) {
55
+ session = s;
56
+ },
57
+ async start() {
58
+ if (!session) throw lifecycleError('not-attached');
59
+ if (cleanup) throw lifecycleError('already-started');
60
+ // ARCH-030: this adapter is the carrier here, so it builds the connection's outbound boundary
61
+ // from its own injected sink and its own failure policy, then passes it down.
62
+ const deliver = createOutboundDelivery(options.send, (error, event) => {
63
+ handler.cleanup();
64
+ cleanup = null;
65
+ transport.onMessage = null;
66
+ options.onDeliveryError?.(error, event);
67
+ });
68
+ const handler = createSessionMessageHandler({
69
+ session,
70
+ deliver,
71
+ ...(options.driverId ? { driverId: options.driverId } : {}),
72
+ ...(options.surface ? { surface: options.surface } : {}),
73
+ ...(options.personalUsageReporter
74
+ ? { personalUsageReporter: options.personalUsageReporter }
75
+ : {}),
76
+ ...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),
77
+ ...(options.storedSessionUsageReporter
78
+ ? { storedSessionUsageReporter: options.storedSessionUsageReporter }
79
+ : {}),
80
+ ...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),
81
+ ...(options.settingsReporter ? { settingsReporter: options.settingsReporter } : {}),
82
+ });
83
+ cleanup = handler.cleanup;
84
+ this.onMessage = handler.onMessage;
85
+ },
86
+ async stop() {
87
+ cleanup?.();
88
+ cleanup = null;
89
+ this.onMessage = null;
90
+ session = null;
91
+ },
92
+ };
93
+ return transport;
94
+ }