agent-relay 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +8 -8
  2. package/packages/acp-bridge/package.json +2 -2
  3. package/packages/config/package.json +1 -1
  4. package/packages/hooks/package.json +4 -4
  5. package/packages/memory/package.json +2 -2
  6. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
  7. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
  8. package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
  9. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
  10. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +617 -0
  11. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
  12. package/packages/openclaw/dist/__tests__/spawn-manager.test.js +29 -0
  13. package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -1
  14. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
  15. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
  16. package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
  17. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
  18. package/packages/openclaw/dist/cli.js +1 -1
  19. package/packages/openclaw/dist/cli.js.map +1 -1
  20. package/packages/openclaw/dist/gateway.d.ts +33 -7
  21. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  22. package/packages/openclaw/dist/gateway.js +101 -50
  23. package/packages/openclaw/dist/gateway.js.map +1 -1
  24. package/packages/openclaw/dist/types.d.ts +5 -1
  25. package/packages/openclaw/dist/types.d.ts.map +1 -1
  26. package/packages/openclaw/package.json +2 -2
  27. package/packages/openclaw/skill/SKILL.md +35 -13
  28. package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
  29. package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
  30. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +746 -0
  31. package/packages/openclaw/src/__tests__/spawn-manager.test.ts +37 -0
  32. package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
  33. package/packages/openclaw/src/cli.ts +1 -1
  34. package/packages/openclaw/src/gateway.ts +129 -56
  35. package/packages/openclaw/src/types.ts +5 -1
  36. package/packages/policy/package.json +2 -2
  37. package/packages/sdk/package.json +2 -2
  38. package/packages/sdk-py/pyproject.toml +1 -1
  39. package/packages/telemetry/package.json +1 -1
  40. package/packages/trajectory/package.json +2 -2
  41. package/packages/user-directory/package.json +2 -2
  42. package/packages/utils/package.json +2 -2
@@ -0,0 +1,192 @@
1
+ # Spec: OpenClawGatewayClient WebSocket Testing
2
+
3
+ ## Problem
4
+
5
+ The `OpenClawGatewayClient` class in `gateway.ts` handles WebSocket connection, Ed25519 challenge-response auth, RPC message delivery, and automatic reconnection. It currently has 0% test coverage because it opens real WebSocket connections that are hard to mock.
6
+
7
+ ## Approach: In-process Mock WebSocket Server
8
+
9
+ Use the `ws` package (already a dependency) to spin up a lightweight `WebSocketServer` on a random port within the test process. The mock server implements just enough of the OpenClaw gateway protocol to exercise all client code paths.
10
+
11
+ ## Test Infrastructure
12
+
13
+ ### MockOpenClawServer
14
+
15
+ ```typescript
16
+ import WebSocket, { WebSocketServer } from 'ws';
17
+ import { AddressInfo } from 'node:net';
18
+
19
+ class MockOpenClawServer {
20
+ private wss: WebSocketServer;
21
+ port: number;
22
+ connections: WebSocket[] = [];
23
+ receivedMessages: Record<string, unknown>[] = [];
24
+
25
+ /** Control flags — tests toggle these to simulate server behavior. */
26
+ rejectAuth = false;
27
+ skipChallenge = false;
28
+ rpcDelay = 0; // ms delay before responding to RPCs
29
+ rpcError = false; // respond to chat.send with an error
30
+
31
+ constructor() {
32
+ this.wss = new WebSocketServer({ port: 0 }); // random port
33
+ this.port = (this.wss.address() as AddressInfo).port;
34
+ this.wss.on('connection', (ws) => this.handleConnection(ws));
35
+ }
36
+
37
+ private handleConnection(ws: WebSocket): void {
38
+ this.connections.push(ws);
39
+
40
+ // Step 1: Send connect.challenge
41
+ if (!this.skipChallenge) {
42
+ ws.send(JSON.stringify({
43
+ type: 'event',
44
+ event: 'connect.challenge',
45
+ payload: { nonce: 'test-nonce-123', ts: Date.now() },
46
+ }));
47
+ }
48
+
49
+ ws.on('message', (data) => {
50
+ const msg = JSON.parse(data.toString());
51
+ this.receivedMessages.push(msg);
52
+
53
+ // Step 2: Handle connect request
54
+ if (msg.method === 'connect') {
55
+ ws.send(JSON.stringify({
56
+ type: 'res',
57
+ id: msg.id,
58
+ ok: !this.rejectAuth,
59
+ ...(this.rejectAuth ? { error: 'auth rejected' } : {}),
60
+ }));
61
+ return;
62
+ }
63
+
64
+ // Step 3: Handle chat.send RPC
65
+ if (msg.method === 'chat.send') {
66
+ setTimeout(() => {
67
+ ws.send(JSON.stringify({
68
+ type: 'res',
69
+ id: msg.id,
70
+ ok: !this.rpcError,
71
+ ...(this.rpcError
72
+ ? { error: 'delivery failed' }
73
+ : { payload: { runId: 'run_1', status: 'ok' } }),
74
+ }));
75
+ }, this.rpcDelay);
76
+ return;
77
+ }
78
+ });
79
+ }
80
+
81
+ /** Forcibly close all connections (simulates server crash). */
82
+ disconnectAll(): void {
83
+ for (const ws of this.connections) ws.close(1006);
84
+ this.connections = [];
85
+ }
86
+
87
+ async close(): Promise<void> {
88
+ this.disconnectAll();
89
+ return new Promise((resolve) => this.wss.close(() => resolve()));
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## Test Cases
95
+
96
+ ### 1. Connection & Authentication
97
+
98
+ | Test | What it validates |
99
+ |---|---|
100
+ | `should connect and authenticate via challenge-response` | Full happy path: challenge → sign → connect response |
101
+ | `should reject when server denies auth` | `connect()` rejects when server returns `ok: false` |
102
+ | `should timeout if no challenge arrives` | `connect()` rejects after `CONNECT_TIMEOUT_MS` when `skipChallenge=true` |
103
+ | `should resolve immediately if already connected` | Second `connect()` call is a no-op |
104
+
105
+ ### 2. Message Delivery (chat.send RPC)
106
+
107
+ | Test | What it validates |
108
+ |---|---|
109
+ | `should send chat.send and resolve true on success` | `sendChatMessage()` returns `true` |
110
+ | `should send idempotencyKey when provided` | Verify `params.idempotencyKey` in received message |
111
+ | `should resolve false when RPC returns error` | `rpcError=true` → returns `false` |
112
+ | `should resolve false on RPC timeout` | `rpcDelay=20000` → hits 15s timeout, returns `false` |
113
+ | `should reconnect and retry if not connected` | Disconnect, call `sendChatMessage`, verify reconnection |
114
+
115
+ ### 3. Reconnection
116
+
117
+ | Test | What it validates |
118
+ |---|---|
119
+ | `should reconnect after server disconnects` | `disconnectAll()` → client reconnects within ~3s |
120
+ | `should not reconnect after stop()` | `disconnect()` then `disconnectAll()` → no reconnection |
121
+ | `should reject pending RPCs on disconnect` | In-flight `sendChatMessage` resolves `false` on disconnect |
122
+
123
+ ### 4. Ed25519 Signature Verification
124
+
125
+ | Test | What it validates |
126
+ |---|---|
127
+ | `should produce valid Ed25519 signature` | Mock server verifies the signature using the client's public key from the connect payload |
128
+ | `should include correct v3 payload fields` | Verify clientId, clientMode, platform, role, scopes, nonce |
129
+
130
+ ## Implementation Notes
131
+
132
+ - Each test creates its own `MockOpenClawServer` and `OpenClawGatewayClient` for full isolation.
133
+ - The `OpenClawGatewayClient` class is currently not exported. Either:
134
+ - (a) Export it (simplest), or
135
+ - (b) Test indirectly through `InboundGateway` with a real mock WS server (heavier but no API changes).
136
+ - Recommended: export the class with a `@internal` JSDoc tag.
137
+ - Tests should use `afterEach` to close both the mock server and client to prevent port leaks.
138
+
139
+ ## E2E Integration Tests
140
+
141
+ Separate from the WS unit tests, create integration tests following the broker harness pattern in `tests/integration/broker/`:
142
+
143
+ ### Test: Full gateway message flow with real Relaycast
144
+
145
+ ```
146
+ 1. Create ephemeral Relaycast workspace (RelayCast.createWorkspace)
147
+ 2. Register two agents: "sender" and "viewer-test-claw"
148
+ 3. Start InboundGateway with the workspace key
149
+ 4. Post a message to #general via sender agent
150
+ 5. Assert the gateway's relaySender.sendMessage was called with correct format
151
+ 6. Post a DM from sender to viewer-test-claw
152
+ 7. Assert DM delivery with [relaycast:dm] format
153
+ 8. Add a reaction via sender
154
+ 9. Assert reaction soft notification delivery
155
+ 10. Cleanup: stop gateway, workspace is ephemeral
156
+ ```
157
+
158
+ ### Test: Gateway reconnection resilience
159
+
160
+ ```
161
+ 1. Start gateway with real Relaycast connection
162
+ 2. Force-disconnect the SDK WebSocket (call relayAgentClient.disconnect())
163
+ 3. Wait for reconnection
164
+ 4. Post a message
165
+ 5. Assert message is still delivered
166
+ ```
167
+
168
+ ### Prerequisites
169
+
170
+ These tests require network access to `api.relaycast.dev` and should:
171
+ - Use `checkPrerequisites()` pattern from broker harness
172
+ - Be skippable via `skipIfMissing()`
173
+ - Have generous timeouts (120s)
174
+ - Use unique channel/agent names with timestamp suffixes
175
+
176
+ ## File Locations
177
+
178
+ ```
179
+ packages/openclaw/src/__tests__/
180
+ gateway-threads.test.ts # Existing unit tests (vitest)
181
+ ws-client.test.ts # NEW: WebSocket client unit tests (vitest)
182
+
183
+ tests/integration/openclaw/
184
+ gateway-e2e.test.ts # NEW: Full integration tests (node:test)
185
+ utils/gateway-harness.ts # NEW: Gateway test harness
186
+ ```
187
+
188
+ ## Estimated Effort
189
+
190
+ - WS client unit tests: ~2-3 hours
191
+ - E2E integration tests: ~3-4 hours
192
+ - Total: ~1 day
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const eventHandlers: Record<string, Array<(...args: unknown[]) => void>> = {};
9
+
10
+ function registerHandler(event: string) {
11
+ return (handler: (...args: unknown[]) => void) => {
12
+ if (!eventHandlers[event]) eventHandlers[event] = [];
13
+ eventHandlers[event].push(handler);
14
+ return () => {
15
+ eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler);
16
+ };
17
+ };
18
+ }
19
+
20
+ const mockAgentClient = {
21
+ connect: vi.fn(),
22
+ disconnect: vi.fn().mockResolvedValue(undefined),
23
+ subscribe: vi.fn(),
24
+ channels: {
25
+ join: vi.fn().mockResolvedValue({ ok: true }),
26
+ create: vi.fn().mockResolvedValue({ name: 'general' }),
27
+ },
28
+ on: {
29
+ connected: registerHandler('connected'),
30
+ messageCreated: registerHandler('messageCreated'),
31
+ threadReply: registerHandler('threadReply'),
32
+ dmReceived: registerHandler('dmReceived'),
33
+ groupDmReceived: registerHandler('groupDmReceived'),
34
+ commandInvoked: registerHandler('commandInvoked'),
35
+ reactionAdded: registerHandler('reactionAdded'),
36
+ reactionRemoved: registerHandler('reactionRemoved'),
37
+ reconnecting: registerHandler('reconnecting'),
38
+ disconnected: registerHandler('disconnected'),
39
+ error: registerHandler('error'),
40
+ any: registerHandler('any'),
41
+ },
42
+ };
43
+
44
+ vi.mock('@relaycast/sdk', () => ({
45
+ RelayCast: vi.fn().mockImplementation(() => ({
46
+ agents: {
47
+ registerOrGet: vi.fn().mockResolvedValue({ name: 'viewer-test-claw', token: 'tok_test' }),
48
+ },
49
+ channels: { join: vi.fn().mockResolvedValue({ ok: true }) },
50
+ messages: { list: vi.fn().mockResolvedValue([]) },
51
+ as: vi.fn().mockReturnValue(mockAgentClient),
52
+ })),
53
+ }));
54
+
55
+ const mockSpawnManager = {
56
+ size: 0,
57
+ spawn: vi.fn(),
58
+ release: vi.fn(),
59
+ releaseByName: vi.fn(),
60
+ releaseAll: vi.fn().mockResolvedValue(undefined),
61
+ list: vi.fn().mockReturnValue([]),
62
+ get: vi.fn(),
63
+ };
64
+
65
+ vi.mock('../spawn/manager.js', () => ({
66
+ SpawnManager: vi.fn().mockImplementation(() => mockSpawnManager),
67
+ }));
68
+
69
+ vi.mock('node:fs/promises', () => ({
70
+ readFile: vi.fn().mockResolvedValue('{"spawns":[]}'),
71
+ writeFile: vi.fn().mockResolvedValue(undefined),
72
+ mkdir: vi.fn().mockResolvedValue(undefined),
73
+ }));
74
+
75
+ vi.mock('node:fs', () => ({
76
+ existsSync: vi.fn().mockReturnValue(false),
77
+ }));
78
+
79
+ // We do NOT mock node:http — we want a real HTTP server for these tests.
80
+ // But we need to intercept createServer in InboundGateway so we can control the port.
81
+ // Strategy: let gateway start its own server, then hit it via fetch().
82
+
83
+ // We need to override RELAYCAST_CONTROL_PORT to use port 0 (random)
84
+ // Actually, we can't use port 0 because the gateway hardcodes the listen call.
85
+ // Instead, let's mock node:http to capture the request handler, then run a real server.
86
+
87
+ let capturedHandler: ((req: IncomingMessage, res: ServerResponse) => void) | null = null;
88
+ let realServer: HttpServer | null = null;
89
+ let controlPort = 0;
90
+
91
+ vi.mock('node:http', async (importOriginal) => {
92
+ const actual = await importOriginal<typeof import('node:http')>();
93
+ return {
94
+ ...actual,
95
+ createServer: vi.fn((handler: (req: IncomingMessage, res: ServerResponse) => void) => {
96
+ capturedHandler = handler;
97
+ // Create a real HTTP server with the captured handler
98
+ realServer = actual.createServer(handler);
99
+ return {
100
+ listen: vi.fn((_port: number, _host: string, cb: () => void) => {
101
+ // Bind to random port
102
+ realServer!.listen(0, '127.0.0.1', () => {
103
+ const addr = realServer!.address() as { port: number };
104
+ controlPort = addr.port;
105
+ cb();
106
+ });
107
+ }),
108
+ close: vi.fn((cb?: () => void) => {
109
+ realServer?.close(() => cb?.());
110
+ }),
111
+ address: vi.fn(() => realServer?.address()),
112
+ on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => {}),
113
+ };
114
+ }),
115
+ };
116
+ });
117
+
118
+ import { InboundGateway } from '../gateway.js';
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ async function fetchControl(method: string, path: string, body?: unknown): Promise<Response> {
125
+ const url = `http://127.0.0.1:${controlPort}${path}`;
126
+ const opts: RequestInit = { method };
127
+ if (body !== undefined) {
128
+ opts.body = JSON.stringify(body);
129
+ opts.headers = { 'Content-Type': 'application/json' };
130
+ }
131
+ return fetch(url, opts);
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Tests
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe('Gateway control HTTP server', () => {
139
+ let gateway: InboundGateway;
140
+
141
+ beforeEach(async () => {
142
+ vi.clearAllMocks();
143
+ for (const key of Object.keys(eventHandlers)) {
144
+ eventHandlers[key] = [];
145
+ }
146
+ // Reset mock spawn manager state
147
+ mockSpawnManager.size = 0;
148
+ mockSpawnManager.spawn.mockReset();
149
+ mockSpawnManager.release.mockReset();
150
+ mockSpawnManager.releaseByName.mockReset();
151
+ mockSpawnManager.list.mockReturnValue([]);
152
+
153
+ gateway = new InboundGateway({
154
+ config: {
155
+ apiKey: 'rk_live_test',
156
+ clawName: 'test-claw',
157
+ baseUrl: 'https://api.relaycast.dev',
158
+ channels: ['general'],
159
+ },
160
+ relaySender: { sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt_1' }) },
161
+ });
162
+ await gateway.start();
163
+ });
164
+
165
+ afterEach(async () => {
166
+ await gateway.stop();
167
+ if (realServer) {
168
+ realServer.close();
169
+ realServer = null;
170
+ }
171
+ });
172
+
173
+ it('GET /health returns 200', async () => {
174
+ const res = await fetchControl('GET', '/health');
175
+ expect(res.status).toBe(200);
176
+ const data = await res.json() as Record<string, unknown>;
177
+ expect(data.ok).toBe(true);
178
+ expect(data.status).toBe('running');
179
+ expect(typeof data.uptime).toBe('number');
180
+ });
181
+
182
+ it('POST /spawn with name returns 200', async () => {
183
+ mockSpawnManager.spawn.mockResolvedValue({
184
+ id: 'spawn-1',
185
+ displayName: 'worker-1',
186
+ agentName: 'claw-ws-worker-1',
187
+ gatewayPort: 18800,
188
+ });
189
+ mockSpawnManager.size = 1;
190
+
191
+ const res = await fetchControl('POST', '/spawn', {
192
+ name: 'worker-1',
193
+ role: 'researcher',
194
+ });
195
+ expect(res.status).toBe(200);
196
+ const data = await res.json() as Record<string, unknown>;
197
+ expect(data.ok).toBe(true);
198
+ expect(data.name).toBe('worker-1');
199
+ expect(data.agentName).toBe('claw-ws-worker-1');
200
+ expect(data.id).toBe('spawn-1');
201
+ });
202
+
203
+ it('POST /spawn without name returns 400', async () => {
204
+ const res = await fetchControl('POST', '/spawn', { role: 'worker' });
205
+ expect(res.status).toBe(400);
206
+ const data = await res.json() as Record<string, unknown>;
207
+ expect(data.ok).toBe(false);
208
+ expect(data.error).toMatch(/name/i);
209
+ });
210
+
211
+ it('POST /spawn error returns 500', async () => {
212
+ mockSpawnManager.spawn.mockRejectedValue(new Error('Docker unavailable'));
213
+
214
+ const res = await fetchControl('POST', '/spawn', { name: 'worker-1' });
215
+ expect(res.status).toBe(500);
216
+ const data = await res.json() as Record<string, unknown>;
217
+ expect(data.ok).toBe(false);
218
+ expect(data.error).toContain('Docker unavailable');
219
+ });
220
+
221
+ it('GET /list returns 200 with empty list', async () => {
222
+ mockSpawnManager.list.mockReturnValue([]);
223
+
224
+ const res = await fetchControl('GET', '/list');
225
+ expect(res.status).toBe(200);
226
+ const data = await res.json() as Record<string, unknown>;
227
+ expect(data.ok).toBe(true);
228
+ expect(data.active).toBe(0);
229
+ expect(data.claws).toEqual([]);
230
+ });
231
+
232
+ it('GET /list returns handles', async () => {
233
+ mockSpawnManager.list.mockReturnValue([
234
+ { id: 's1', displayName: 'alpha', agentName: 'claw-alpha', gatewayPort: 18801 },
235
+ ]);
236
+
237
+ const res = await fetchControl('GET', '/list');
238
+ expect(res.status).toBe(200);
239
+ const data = await res.json() as { claws: Array<{ name: string }> };
240
+ expect(data.claws).toHaveLength(1);
241
+ expect(data.claws[0].name).toBe('alpha');
242
+ });
243
+
244
+ it('POST /release by name returns 200', async () => {
245
+ mockSpawnManager.releaseByName.mockResolvedValue(true);
246
+ mockSpawnManager.size = 0;
247
+
248
+ const res = await fetchControl('POST', '/release', { name: 'worker-1' });
249
+ expect(res.status).toBe(200);
250
+ const data = await res.json() as Record<string, unknown>;
251
+ expect(data.ok).toBe(true);
252
+ });
253
+
254
+ it('POST /release by id returns 200', async () => {
255
+ mockSpawnManager.release.mockResolvedValue(true);
256
+ mockSpawnManager.size = 0;
257
+
258
+ const res = await fetchControl('POST', '/release', { id: 'spawn-1' });
259
+ expect(res.status).toBe(200);
260
+ const data = await res.json() as Record<string, unknown>;
261
+ expect(data.ok).toBe(true);
262
+ });
263
+
264
+ it('POST /release without name or id returns 400', async () => {
265
+ const res = await fetchControl('POST', '/release', {});
266
+ expect(res.status).toBe(400);
267
+ const data = await res.json() as Record<string, unknown>;
268
+ expect(data.ok).toBe(false);
269
+ expect(data.error).toMatch(/name.*id|id.*name/i);
270
+ });
271
+
272
+ it('POST /release error returns 500', async () => {
273
+ mockSpawnManager.release.mockRejectedValue(new Error('Process kill failed'));
274
+
275
+ const res = await fetchControl('POST', '/release', { id: 'spawn-1' });
276
+ expect(res.status).toBe(500);
277
+ const data = await res.json() as Record<string, unknown>;
278
+ expect(data.ok).toBe(false);
279
+ expect(data.error).toContain('Process kill failed');
280
+ });
281
+
282
+ it('GET /unknown returns 404', async () => {
283
+ const res = await fetchControl('GET', '/nonexistent');
284
+ expect(res.status).toBe(404);
285
+ const data = await res.json() as Record<string, unknown>;
286
+ expect(data.error).toBe('Not found');
287
+ });
288
+ });