@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.
- package/CHANGELOG.md +564 -0
- package/LICENSE +661 -0
- package/README.md +85 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +180 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +180 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +195 -0
- package/src/__tests__/payload-channels.test.ts +233 -0
- package/src/__tests__/ws-payload-channel.e2e.test.ts +255 -0
- package/src/__tests__/ws-session-binder.test.ts +271 -0
- package/src/__tests__/ws-session-delivery.test.ts +86 -0
- package/src/__tests__/ws-session-directory.test.ts +97 -0
- package/src/__tests__/ws-transport-auth.test.ts +253 -0
- package/src/__tests__/ws-transport-lifecycle.test.ts +135 -0
- package/src/__tests__/ws-transport.test.ts +117 -0
- package/src/index.ts +7 -0
- package/src/payload-channels.ts +204 -0
- package/src/ws-connection-guards.ts +104 -0
- package/src/ws-message-data.ts +8 -0
- package/src/ws-session-delivery.ts +109 -0
- package/src/ws-transport-config.ts +133 -0
- package/src/ws-transport-configurable.ts +337 -0
- package/src/ws-transport.ts +94 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: the host hands its session directory to the WebSocket carrier once, and every connection can
|
|
3
|
+
* list, start and switch the host's sessions through it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createWsTransport } from '../ws-transport.js';
|
|
11
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
12
|
+
|
|
13
|
+
import type { ISessionDirectory, ISessionListing } from '@robota-sdk/agent-interface-session';
|
|
14
|
+
import type { TServerMessage } from '@robota-sdk/agent-transport';
|
|
15
|
+
|
|
16
|
+
const listing: ISessionListing = {
|
|
17
|
+
currentSessionId: 'session-1',
|
|
18
|
+
sessions: [],
|
|
19
|
+
unreadableSessionIds: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function createDirectory(): ISessionDirectory {
|
|
23
|
+
return {
|
|
24
|
+
listSessions: vi.fn(() => listing),
|
|
25
|
+
switchSession: vi.fn(async () => undefined),
|
|
26
|
+
newSession: vi.fn(async () => undefined),
|
|
27
|
+
renameSession: vi.fn(async () => undefined),
|
|
28
|
+
deleteSession: vi.fn(async () => undefined),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mockSession(): ReturnType<typeof createTestInteractiveSession> {
|
|
33
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
34
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
35
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
36
|
+
on: vi.fn(),
|
|
37
|
+
off: vi.fn(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const started: WsTransport[] = [];
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
while (started.length) await started.pop()!.stop();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('WsTransport session directory (#3189)', () => {
|
|
47
|
+
it('lets every connection list and switch the host sessions', async () => {
|
|
48
|
+
const directory = createDirectory();
|
|
49
|
+
const transport = new WsTransport({
|
|
50
|
+
port: 17720,
|
|
51
|
+
maxRetries: 30,
|
|
52
|
+
open: true,
|
|
53
|
+
openReason: 'session-directory regression test',
|
|
54
|
+
sessionDirectory: directory,
|
|
55
|
+
});
|
|
56
|
+
transport.attach(mockSession());
|
|
57
|
+
await transport.start();
|
|
58
|
+
started.push(transport);
|
|
59
|
+
|
|
60
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
61
|
+
const received: TServerMessage[] = [];
|
|
62
|
+
ws.on('message', (data) => received.push(JSON.parse(String(data)) as TServerMessage));
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
ws.on('open', resolve);
|
|
65
|
+
ws.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ws.send(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
|
|
69
|
+
ws.send(JSON.stringify({ type: 'switch-session', sessionId: 'session-2' }));
|
|
70
|
+
await vi.waitFor(() => expect(directory.switchSession).toHaveBeenCalledWith('session-2'));
|
|
71
|
+
await vi.waitFor(() =>
|
|
72
|
+
expect(received).toContainEqual({ type: 'sessions', requestId: 'r1', listing }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
76
|
+
ws.close();
|
|
77
|
+
await closed;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('passes the directory through the adapter form as well', async () => {
|
|
81
|
+
const directory = createDirectory();
|
|
82
|
+
const sent: TServerMessage[] = [];
|
|
83
|
+
const transport = createWsTransport({
|
|
84
|
+
send: (message) => sent.push(message),
|
|
85
|
+
sessionDirectory: directory,
|
|
86
|
+
});
|
|
87
|
+
transport.attach(mockSession());
|
|
88
|
+
await transport.start();
|
|
89
|
+
|
|
90
|
+
transport.onMessage?.(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
|
|
91
|
+
transport.onMessage?.(JSON.stringify({ type: 'new-session' }));
|
|
92
|
+
|
|
93
|
+
expect(sent).toEqual([{ type: 'sessions', requestId: 'r1', listing }]);
|
|
94
|
+
await vi.waitFor(() => expect(directory.newSession).toHaveBeenCalledTimes(1));
|
|
95
|
+
await transport.stop();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
7
|
+
|
|
8
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GUI-002 TC-03 — required loopback auth token. A configured token MUST be presented (query param or
|
|
12
|
+
* `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any session data.
|
|
13
|
+
*
|
|
14
|
+
* SEC-001 — secure by default: when NO token is configured, the transport AUTO-MINTS one (`resolvedToken`),
|
|
15
|
+
* so an unauthenticated connection is rejected; `{ open: true }` is the discouraged opt-out. Plus
|
|
16
|
+
* defense-in-depth: the WS upgrade rejects a non-loopback `Host` (DNS rebinding) and a non-allowed browser
|
|
17
|
+
* `Origin` at the handshake.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function mockSession(): IInteractiveSession {
|
|
21
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
22
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
23
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
24
|
+
on: vi.fn(),
|
|
25
|
+
off: vi.fn(),
|
|
26
|
+
submit: vi.fn(),
|
|
27
|
+
abort: vi.fn(),
|
|
28
|
+
cancelQueue: vi.fn(),
|
|
29
|
+
executeCommand: vi.fn(),
|
|
30
|
+
resolvePermission: vi.fn(),
|
|
31
|
+
resolveAsk: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const started: WsTransport[] = [];
|
|
36
|
+
|
|
37
|
+
interface IStartConfig {
|
|
38
|
+
token?: string;
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** SEC-008: required alongside `open` — the transport refuses an opt-out nobody explained. */
|
|
41
|
+
openReason?: string;
|
|
42
|
+
allowedHosts?: readonly string[];
|
|
43
|
+
allowedOrigins?: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Start a transport and return its port + the resolved token (auto-minted when none was configured). */
|
|
47
|
+
async function startOn(config: IStartConfig = {}): Promise<{ port: number; token?: string }> {
|
|
48
|
+
// Use a high, unlikely-occupied base port; retry covers rare collisions.
|
|
49
|
+
const port = 17000 + Math.floor((started.length + 1) * 7);
|
|
50
|
+
const t = new WsTransport({ port, maxRetries: 30, ...config });
|
|
51
|
+
t.attach(mockSession());
|
|
52
|
+
await t.start();
|
|
53
|
+
started.push(t);
|
|
54
|
+
return { port, token: t.resolvedToken };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
while (started.length) await started.pop()!.stop();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Connect and resolve with the outcome: `'messages'` if a `messages` frame arrives first, or `'closed'`
|
|
63
|
+
* with the close code if the socket closes before any frame. 1.5s guard so a hang fails loudly.
|
|
64
|
+
*/
|
|
65
|
+
function probe(
|
|
66
|
+
port: number,
|
|
67
|
+
opts: { token?: string; subprotocol?: string; headers?: Record<string, string> } = {},
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const url = `ws://127.0.0.1:${port}${opts.token ? `?token=${encodeURIComponent(opts.token)}` : ''}`;
|
|
70
|
+
const wsOpts = opts.headers ? { headers: opts.headers } : {};
|
|
71
|
+
const ws = opts.subprotocol
|
|
72
|
+
? new WebSocket(url, opts.subprotocol, wsOpts)
|
|
73
|
+
: new WebSocket(url, wsOpts);
|
|
74
|
+
return new Promise<string>((resolve) => {
|
|
75
|
+
const done = (v: string): void => {
|
|
76
|
+
try {
|
|
77
|
+
ws.close();
|
|
78
|
+
} catch {
|
|
79
|
+
/* already closing */
|
|
80
|
+
}
|
|
81
|
+
resolve(v);
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => done('timeout'), 1500);
|
|
84
|
+
ws.on('message', (data) => {
|
|
85
|
+
const msg = JSON.parse(String(data)) as { type: string };
|
|
86
|
+
if (msg.type === 'messages') {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
done('messages');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
ws.on('close', (code) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
done(`closed:${code}`);
|
|
94
|
+
});
|
|
95
|
+
ws.on('error', () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
done('closed:error');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('WsTransport loopback auth (GUI-002 TC-03)', () => {
|
|
103
|
+
it('attributes submissions with the trusted server-side surface driver id', async () => {
|
|
104
|
+
const port = 17650;
|
|
105
|
+
const submit = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
const session = Object.assign(mockSession(), { submit });
|
|
107
|
+
const transport = new WsTransport({
|
|
108
|
+
port,
|
|
109
|
+
maxRetries: 30,
|
|
110
|
+
open: true,
|
|
111
|
+
openReason: 'surface-attribution regression test',
|
|
112
|
+
driverId: 'app',
|
|
113
|
+
surface: 'desktop-app',
|
|
114
|
+
});
|
|
115
|
+
transport.attach(session);
|
|
116
|
+
await transport.start();
|
|
117
|
+
started.push(transport);
|
|
118
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
119
|
+
await new Promise<void>((resolve, reject) => {
|
|
120
|
+
ws.on('open', resolve);
|
|
121
|
+
ws.on('error', reject);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.send(JSON.stringify({ type: 'submit', prompt: 'attribute me' }));
|
|
125
|
+
await vi.waitFor(() => expect(submit).toHaveBeenCalled());
|
|
126
|
+
|
|
127
|
+
expect(submit).toHaveBeenCalledWith('attribute me', undefined, undefined, {
|
|
128
|
+
driverId: 'app',
|
|
129
|
+
surface: 'desktop-app',
|
|
130
|
+
});
|
|
131
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
132
|
+
ws.close();
|
|
133
|
+
await closed;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('carries the driver id on the connection-open messages frame, so a client can learn its own id (#3289 §3)', async () => {
|
|
137
|
+
// The crux of #3289 §3's own-driver-id feature: a client's first `messages` frame is where it
|
|
138
|
+
// learns which of every subsequent `authored` message is its own. This is the one place that
|
|
139
|
+
// sets it — decode-shape validation and the client reducer are covered elsewhere, but neither
|
|
140
|
+
// would catch this specific line being broken or reverted.
|
|
141
|
+
const port = 17660;
|
|
142
|
+
const transport = new WsTransport({
|
|
143
|
+
port,
|
|
144
|
+
maxRetries: 30,
|
|
145
|
+
open: true,
|
|
146
|
+
openReason: 'own-driver-id regression test',
|
|
147
|
+
driverId: 'browser',
|
|
148
|
+
surface: 'browser',
|
|
149
|
+
});
|
|
150
|
+
transport.attach(mockSession());
|
|
151
|
+
await transport.start();
|
|
152
|
+
started.push(transport);
|
|
153
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
154
|
+
const firstMessagesFrame = await new Promise<{ type: string; driverId?: string }>(
|
|
155
|
+
(resolve, reject) => {
|
|
156
|
+
ws.on('error', reject);
|
|
157
|
+
ws.on('message', (data) => {
|
|
158
|
+
const msg = JSON.parse(String(data)) as { type: string; driverId?: string };
|
|
159
|
+
if (msg.type === 'messages') resolve(msg);
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
expect(firstMessagesFrame.driverId).toBe('browser');
|
|
164
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
165
|
+
ws.close();
|
|
166
|
+
await closed;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('accepts a connection presenting the correct token via query param', async () => {
|
|
170
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
171
|
+
expect(await probe(port, { token: 'secret-nonce' })).toBe('messages');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('accepts the correct token via Sec-WebSocket-Protocol subprotocol', async () => {
|
|
175
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
176
|
+
expect(await probe(port, { subprotocol: 'secret-nonce' })).toBe('messages');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects a MISSING token before any session data is emitted', async () => {
|
|
180
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
181
|
+
expect(await probe(port)).toBe('closed:1008'); // never 'messages'
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('rejects a WRONG token before any session data is emitted', async () => {
|
|
185
|
+
const { port } = await startOn({ token: 'secret-nonce' });
|
|
186
|
+
expect(await probe(port, { token: 'not-the-nonce' })).toBe('closed:1008');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── SEC-001 ────────────────────────────────────────────────────────────────
|
|
190
|
+
it('TC-01: NO token configured AUTO-MINTS one — unauthenticated rejected, minted token works', async () => {
|
|
191
|
+
const { port, token } = await startOn({}); // secure by default
|
|
192
|
+
expect(token).toBeTruthy();
|
|
193
|
+
expect(await probe(port)).toBe('closed:1008');
|
|
194
|
+
expect(await probe(port, { token })).toBe('messages');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('TC-05: the discouraged `open` opt-out restores unauthenticated behavior', async () => {
|
|
198
|
+
const { port, token } = await startOn({
|
|
199
|
+
open: true,
|
|
200
|
+
openReason: 'SEC-008: the discouraged opt-out is the SUBJECT of this case',
|
|
201
|
+
});
|
|
202
|
+
expect(token).toBeUndefined();
|
|
203
|
+
expect(await probe(port)).toBe('messages');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('TC-02: rejects a non-loopback Host at the upgrade (DNS-rebinding defense)', async () => {
|
|
207
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
208
|
+
expect(await probe(port, { token, headers: { host: 'evil.example.com' } })).not.toBe(
|
|
209
|
+
'messages',
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('TC-02: accepts a loopback Host on a port-agnostic match', async () => {
|
|
214
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
215
|
+
expect(await probe(port, { token, headers: { host: `localhost:${port}` } })).toBe('messages');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('TC-03: rejects a non-allowed browser Origin at the upgrade', async () => {
|
|
219
|
+
const { port, token } = await startOn({ token: 'secret-nonce' });
|
|
220
|
+
expect(await probe(port, { token, headers: { origin: 'https://evil.example.com' } })).not.toBe(
|
|
221
|
+
'messages',
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('TC-03: accepts a configured app Origin', async () => {
|
|
226
|
+
const { port, token } = await startOn({
|
|
227
|
+
token: 'secret-nonce',
|
|
228
|
+
allowedOrigins: ['https://app.example.com'],
|
|
229
|
+
});
|
|
230
|
+
expect(await probe(port, { token, headers: { origin: 'https://app.example.com' } })).toBe(
|
|
231
|
+
'messages',
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('SEC-008: `open` takes a written reason here too', () => {
|
|
237
|
+
// The first version filled a reason in for any caller that omitted one, which made WS the ONE
|
|
238
|
+
// transport where `{ open: true }` alone was accepted — HTTP and WebRTC both refuse it. Review
|
|
239
|
+
// found that: a reason invented on the caller's behalf reads, to the next person, as a decision
|
|
240
|
+
// somebody made, and the whole point of requiring it is that nobody can produce one by accident.
|
|
241
|
+
it('refuses an opt-out with no reason', () => {
|
|
242
|
+
expect(() => new WsTransport({ port: 17999, open: true })).toThrow(/openReason/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('accepts one that says why', () => {
|
|
246
|
+
// Without this the fix would be "refuse every open transport", which is not the decision — an
|
|
247
|
+
// open transport is legitimate, it just has to be chosen out loud.
|
|
248
|
+
expect(
|
|
249
|
+
() =>
|
|
250
|
+
new WsTransport({ port: 17999, open: true, openReason: 'loopback only — no remote peer' }),
|
|
251
|
+
).not.toThrow();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
4
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
5
|
+
|
|
6
|
+
import { WebSocket } from 'ws';
|
|
7
|
+
import { describe, it, expect, expectTypeOf, vi, afterEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
10
|
+
|
|
11
|
+
import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
|
|
12
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
13
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ARCH-004 RUNTIME-13 — `stop()` must resolve even with a client still connected.
|
|
17
|
+
*
|
|
18
|
+
* Before the fix, `stop()` did `wss.close(cb)`, whose callback fires only after every client socket is gone,
|
|
19
|
+
* so a live client made `stop()` hang forever. The fix sends a close frame to each client and terminates any
|
|
20
|
+
* survivor at a 5s deadline, so the server-close callback always resolves.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function mockSession(): IInteractiveSession {
|
|
24
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
25
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
26
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
27
|
+
on: vi.fn(),
|
|
28
|
+
off: vi.fn(),
|
|
29
|
+
submit: vi.fn(),
|
|
30
|
+
abort: vi.fn(),
|
|
31
|
+
cancelQueue: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const started: WsTransport[] = [];
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
while (started.length) await started.pop()!.stop();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('WsTransport lifecycle (ARCH-004 RUNTIME-13)', () => {
|
|
41
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
42
|
+
const transport = new WsTransport({
|
|
43
|
+
open: true,
|
|
44
|
+
openReason: 'type compatibility test',
|
|
45
|
+
});
|
|
46
|
+
expectTypeOf(transport).toMatchTypeOf<IConfigurableTransport<IProtocolSession>>();
|
|
47
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('stop() resolves promptly with a client still connected (previously hung forever)', async () => {
|
|
51
|
+
const t = new WsTransport({
|
|
52
|
+
port: 17800,
|
|
53
|
+
maxRetries: 40,
|
|
54
|
+
open: true,
|
|
55
|
+
openReason: 'SEC-008: this case is about the stop() lifecycle, not admission',
|
|
56
|
+
});
|
|
57
|
+
t.attach(mockSession());
|
|
58
|
+
await t.start();
|
|
59
|
+
started.push(t);
|
|
60
|
+
const port = t.boundPort;
|
|
61
|
+
|
|
62
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
ws.on('open', () => resolve());
|
|
65
|
+
ws.on('error', reject);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const startedAt = Date.now();
|
|
69
|
+
await t.stop(); // must not hang — the whole test would time out if it did
|
|
70
|
+
const elapsed = Date.now() - startedAt;
|
|
71
|
+
|
|
72
|
+
// A well-behaved client closes on the 1001 frame, so stop() resolves well under the 5s terminate deadline.
|
|
73
|
+
expect(elapsed).toBeLessThan(4500);
|
|
74
|
+
started.pop(); // already stopped
|
|
75
|
+
try {
|
|
76
|
+
ws.terminate();
|
|
77
|
+
} catch {
|
|
78
|
+
/* already closed */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('can retry start after a bind failure without an intervening stop', async () => {
|
|
83
|
+
const blocker = createServer();
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
blocker.once('error', reject);
|
|
86
|
+
blocker.listen(0, '127.0.0.1', resolve);
|
|
87
|
+
});
|
|
88
|
+
const address = blocker.address();
|
|
89
|
+
if (!address || typeof address === 'string') throw new Error('Expected a TCP address.');
|
|
90
|
+
|
|
91
|
+
const transport = new WsTransport({
|
|
92
|
+
port: address.port,
|
|
93
|
+
maxRetries: 0,
|
|
94
|
+
open: true,
|
|
95
|
+
openReason: 'bind failure lifecycle regression',
|
|
96
|
+
});
|
|
97
|
+
transport.attach(mockSession());
|
|
98
|
+
let blockerOpen = true;
|
|
99
|
+
try {
|
|
100
|
+
await expect(transport.start()).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
|
101
|
+
await new Promise<void>((resolve, reject) =>
|
|
102
|
+
blocker.close((error) => (error ? reject(error) : resolve())),
|
|
103
|
+
);
|
|
104
|
+
blockerOpen = false;
|
|
105
|
+
|
|
106
|
+
await expect(transport.start()).resolves.toBeUndefined();
|
|
107
|
+
started.push(transport);
|
|
108
|
+
expect(transport.boundPort).toBe(address.port);
|
|
109
|
+
} finally {
|
|
110
|
+
if (blockerOpen) {
|
|
111
|
+
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
117
|
+
await runTransportLifecycleConformance({
|
|
118
|
+
subjectId: '@robota-sdk/agent-transport-ws#WsTransport',
|
|
119
|
+
kind: 'service',
|
|
120
|
+
createAdapter: () =>
|
|
121
|
+
new WsTransport({
|
|
122
|
+
port: 0,
|
|
123
|
+
open: true,
|
|
124
|
+
openReason: 'ARCH-011 lifecycle conformance',
|
|
125
|
+
}),
|
|
126
|
+
createSession: mockSession,
|
|
127
|
+
assertReady: (transport) => {
|
|
128
|
+
if (transport.boundPort === undefined) throw new Error('WS endpoint not bound');
|
|
129
|
+
},
|
|
130
|
+
assertStopped: (transport) => {
|
|
131
|
+
if (transport.boundPort !== undefined) throw new Error('WS endpoint still bound');
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
2
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
|
|
5
|
+
import { createWsTransport } from '../ws-transport.js';
|
|
6
|
+
import type { ITransportAdapter } from '@robota-sdk/agent-interface-transport';
|
|
7
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
8
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
9
|
+
|
|
10
|
+
function createMockSession(): IInteractiveSession {
|
|
11
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
12
|
+
submit: vi.fn(),
|
|
13
|
+
abort: vi.fn(),
|
|
14
|
+
cancelQueue: vi.fn(),
|
|
15
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
16
|
+
getContextState: vi
|
|
17
|
+
.fn()
|
|
18
|
+
.mockReturnValue({ usedPercentage: 0, usedTokens: 0, maxTokens: 200000 }),
|
|
19
|
+
isExecuting: vi.fn().mockReturnValue(false),
|
|
20
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
21
|
+
executeCommand: vi.fn().mockResolvedValue({ message: 'ok', success: true }),
|
|
22
|
+
listCommands: vi.fn().mockReturnValue([]),
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
off: vi.fn(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('createWsTransport', () => {
|
|
29
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
30
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
31
|
+
expectTypeOf(transport).toMatchTypeOf<ITransportAdapter<IProtocolSession>>();
|
|
32
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns an adapter with name "ws"', () => {
|
|
36
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
37
|
+
expect(transport.name).toBe('ws');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws if start() is called without attach()', async () => {
|
|
41
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
42
|
+
await expect(transport.start()).rejects.toMatchObject({
|
|
43
|
+
name: 'TransportLifecycleError',
|
|
44
|
+
code: 'not-attached',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('onMessage is null before start()', () => {
|
|
49
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
50
|
+
expect(transport.onMessage).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('provides onMessage after attach + start', async () => {
|
|
54
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
55
|
+
transport.attach(createMockSession() as never);
|
|
56
|
+
await transport.start();
|
|
57
|
+
expect(typeof transport.onMessage).toBe('function');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('clears onMessage after stop()', async () => {
|
|
61
|
+
const transport = createWsTransport({ send: vi.fn() });
|
|
62
|
+
transport.attach(createMockSession() as never);
|
|
63
|
+
await transport.start();
|
|
64
|
+
await transport.stop();
|
|
65
|
+
expect(transport.onMessage).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('isolates an outbound failure, cleans subscriptions, and reports to the carrier owner', async () => {
|
|
69
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
70
|
+
const session = createTestInteractiveSession({
|
|
71
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
72
|
+
handlers.set(event, handler);
|
|
73
|
+
}) as IInteractiveSession['on'],
|
|
74
|
+
off: ((event: string) => {
|
|
75
|
+
handlers.delete(event);
|
|
76
|
+
}) as IInteractiveSession['off'],
|
|
77
|
+
});
|
|
78
|
+
const onDeliveryError = vi.fn();
|
|
79
|
+
const transport = createWsTransport({
|
|
80
|
+
send: () => {
|
|
81
|
+
throw new Error('functional adapter socket closed');
|
|
82
|
+
},
|
|
83
|
+
onDeliveryError,
|
|
84
|
+
});
|
|
85
|
+
transport.attach(session);
|
|
86
|
+
await transport.start();
|
|
87
|
+
|
|
88
|
+
expect(() =>
|
|
89
|
+
handlers.get('branch_event')?.({
|
|
90
|
+
kind: 'checkpoint_created',
|
|
91
|
+
checkpointId: 'turn-0001',
|
|
92
|
+
branchId: 'main',
|
|
93
|
+
}),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({ message: 'functional adapter socket closed' }),
|
|
97
|
+
'branch_event',
|
|
98
|
+
);
|
|
99
|
+
expect(handlers.size).toBe(0);
|
|
100
|
+
expect(transport.onMessage).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
104
|
+
await runTransportLifecycleConformance({
|
|
105
|
+
subjectId: '@robota-sdk/agent-transport-ws#createWsTransport',
|
|
106
|
+
kind: 'service',
|
|
107
|
+
createAdapter: () => createWsTransport({ send: vi.fn() }),
|
|
108
|
+
createSession: createMockSession,
|
|
109
|
+
assertReady: (transport) => {
|
|
110
|
+
if (typeof transport.onMessage !== 'function') throw new Error('WS handler not ready');
|
|
111
|
+
},
|
|
112
|
+
assertStopped: (transport) => {
|
|
113
|
+
if (transport.onMessage !== null) throw new Error('WS handler still ready');
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createWsTransport } from './ws-transport.js';
|
|
2
|
+
export type { IWsTransport, IWsTransportOptions } from './ws-transport.js';
|
|
3
|
+
export { WsTransport } from './ws-transport-configurable.js';
|
|
4
|
+
export type { IWsTransportConfig } from './ws-transport-config.js';
|
|
5
|
+
// TRANS-001 — payload-agnostic channel multiplexing (also usable standalone by another carrier).
|
|
6
|
+
export { PayloadChannelRegistry } from './payload-channels.js';
|
|
7
|
+
export type { TChannelSink } from './payload-channels.js';
|