@lobu/worker 6.1.1 → 7.0.0
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/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +26 -2
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/gateway/gateway-integration.js +4 -4
- package/dist/gateway/gateway-integration.js.map +1 -1
- package/dist/gateway/message-batcher.d.ts.map +1 -1
- package/dist/gateway/message-batcher.js +3 -5
- package/dist/gateway/message-batcher.js.map +1 -1
- package/dist/gateway/sse-client.d.ts +1 -0
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +8 -0
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/openclaw/worker.d.ts +0 -1
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +18 -75
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +37 -13
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
- package/src/__tests__/custom-tools.test.ts +92 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
- package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
- package/src/__tests__/embedded-tools.test.ts +744 -0
- package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
- package/src/__tests__/exec-sandbox.test.ts +550 -0
- package/src/__tests__/generated-media.test.ts +142 -0
- package/src/__tests__/instructions.test.ts +60 -0
- package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
- package/src/__tests__/mcp-cli-commands.test.ts +383 -0
- package/src/__tests__/mcp-tool-call.test.ts +423 -0
- package/src/__tests__/memory-flush-harden.test.ts +367 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/message-batcher.test.ts +247 -0
- package/src/__tests__/model-resolver-harden.test.ts +197 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor-harden.test.ts +269 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
- package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
- package/src/__tests__/sandbox-leak.test.ts +167 -0
- package/src/__tests__/setup.ts +102 -0
- package/src/__tests__/sse-client-harden.test.ts +588 -0
- package/src/__tests__/sse-client.test.ts +90 -0
- package/src/__tests__/tool-implementations.test.ts +196 -0
- package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +62 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +128 -0
- package/src/core/workspace.ts +89 -0
- package/src/embedded/exec-sandbox.ts +372 -0
- package/src/embedded/just-bash-bootstrap.ts +543 -0
- package/src/embedded/mcp-cli-commands.ts +402 -0
- package/src/gateway/gateway-integration.ts +298 -0
- package/src/gateway/message-batcher.ts +123 -0
- package/src/gateway/sse-client.ts +951 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +141 -0
- package/src/instructions/builder.ts +45 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +315 -0
- package/src/openclaw/instructions.ts +36 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +427 -0
- package/src/openclaw/processor.ts +198 -0
- package/src/openclaw/sandbox-leak.ts +105 -0
- package/src/openclaw/session-context.ts +320 -0
- package/src/openclaw/tool-policy.ts +248 -0
- package/src/openclaw/tools.ts +277 -0
- package/src/openclaw/worker.ts +1847 -0
- package/src/server.ts +334 -0
- package/src/shared/audio-provider-suggestions.ts +132 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +68 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +940 -0
- package/src/shared/worker-env-keys.ts +8 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardening tests for GatewayClient / SSE client.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - SSE reconnect with exponential-backoff delay
|
|
6
|
+
* - Partial / garbled SSE frames (split across chunks, malformed JSON)
|
|
7
|
+
* - Unknown event types are silently ignored
|
|
8
|
+
* - Missing jobId on job events does not break processing
|
|
9
|
+
* - Delivery receipt sent for jobs that have a top-level jobId
|
|
10
|
+
* - consumePendingConfigNotifications lifecycle
|
|
11
|
+
* - Zod validation rejects malformed job payloads
|
|
12
|
+
* - Secret placeholder invariant: no real credential string leaks into logged
|
|
13
|
+
* worker-config or payload fields (the proxy swaps `lobu_secret_<uuid>`
|
|
14
|
+
* placeholders; the worker must only ever see those tokens).
|
|
15
|
+
* - Worker is cleaned up on mid-run stop
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
import {
|
|
20
|
+
GatewayClient,
|
|
21
|
+
consumePendingConfigNotifications,
|
|
22
|
+
} from "../gateway/sse-client";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeJobEvent(overrides: Record<string, unknown> = {}): string {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
payload: {
|
|
31
|
+
botId: "lobu-api",
|
|
32
|
+
userId: "user-1",
|
|
33
|
+
agentId: "agent-1",
|
|
34
|
+
conversationId: "conv-1",
|
|
35
|
+
platform: "api",
|
|
36
|
+
channelId: "chan-1",
|
|
37
|
+
messageId: "msg-1",
|
|
38
|
+
messageText: "hello",
|
|
39
|
+
platformMetadata: {},
|
|
40
|
+
agentOptions: {},
|
|
41
|
+
...overrides,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeClient(dispatcherUrl = "https://gw.example.com") {
|
|
47
|
+
return new GatewayClient(dispatcherUrl, "test-token", "user-1", "worker-1");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// consumePendingConfigNotifications lifecycle
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe("consumePendingConfigNotifications", () => {
|
|
55
|
+
test("returns empty array when no notifications are pending", () => {
|
|
56
|
+
// Drain any notifications from earlier tests first
|
|
57
|
+
consumePendingConfigNotifications();
|
|
58
|
+
expect(consumePendingConfigNotifications()).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns and clears pending config change notifications", async () => {
|
|
62
|
+
consumePendingConfigNotifications(); // drain
|
|
63
|
+
|
|
64
|
+
const client = makeClient();
|
|
65
|
+
const mockFetch = mock(async () => new Response("{}", { status: 200 }));
|
|
66
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
67
|
+
|
|
68
|
+
await (client as any).handleEvent(
|
|
69
|
+
"config_changed",
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
changes: [
|
|
72
|
+
{ category: "provider", action: "updated", summary: "Key rotated" },
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const notifications = consumePendingConfigNotifications();
|
|
78
|
+
expect(notifications).toHaveLength(1);
|
|
79
|
+
expect(notifications[0]).toMatchObject({
|
|
80
|
+
category: "provider",
|
|
81
|
+
action: "updated",
|
|
82
|
+
summary: "Key rotated",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Second call: cleared
|
|
86
|
+
expect(consumePendingConfigNotifications()).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("handles config_changed with no changes array gracefully", async () => {
|
|
90
|
+
consumePendingConfigNotifications(); // drain
|
|
91
|
+
const client = makeClient();
|
|
92
|
+
// Missing `changes` key — backward compat path
|
|
93
|
+
await (client as any).handleEvent(
|
|
94
|
+
"config_changed",
|
|
95
|
+
JSON.stringify({ something: "else" })
|
|
96
|
+
);
|
|
97
|
+
expect(consumePendingConfigNotifications()).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("handles config_changed with invalid JSON gracefully", async () => {
|
|
101
|
+
consumePendingConfigNotifications(); // drain
|
|
102
|
+
const client = makeClient();
|
|
103
|
+
// Should not throw; backward compat ignores bad payload
|
|
104
|
+
await expect(
|
|
105
|
+
(client as any).handleEvent("config_changed", "NOT_JSON")
|
|
106
|
+
).resolves.toBeUndefined();
|
|
107
|
+
expect(consumePendingConfigNotifications()).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Unknown / unsupported event types
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe("handleEvent unknown event types", () => {
|
|
116
|
+
test("silently ignores unknown event type without throwing", async () => {
|
|
117
|
+
const client = makeClient();
|
|
118
|
+
await expect(
|
|
119
|
+
(client as any).handleEvent("mystery_event", JSON.stringify({ x: 1 }))
|
|
120
|
+
).resolves.toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("ignores empty data gracefully", async () => {
|
|
124
|
+
const client = makeClient();
|
|
125
|
+
// The SSE loop skips events where eventData is empty — confirm handleEvent
|
|
126
|
+
// itself also survives an empty string (defensive).
|
|
127
|
+
await expect(
|
|
128
|
+
(client as any).handleEvent("job", "")
|
|
129
|
+
).resolves.toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Job event: Zod validation
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("handleEvent job validation", () => {
|
|
138
|
+
test("rejects job payload missing required fields", async () => {
|
|
139
|
+
const client = makeClient();
|
|
140
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
141
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
142
|
+
|
|
143
|
+
// Missing botId / userId / agentId etc.
|
|
144
|
+
await (client as any).handleEvent(
|
|
145
|
+
"job",
|
|
146
|
+
JSON.stringify({ payload: { messageText: "hi" } })
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Validation should have failed → handleThreadMessage never called
|
|
150
|
+
expect(handleThreadMessage).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects job payload with completely wrong shape", async () => {
|
|
154
|
+
const client = makeClient();
|
|
155
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
156
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
157
|
+
|
|
158
|
+
await (client as any).handleEvent("job", JSON.stringify({ bad: true }));
|
|
159
|
+
expect(handleThreadMessage).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("rejects non-JSON job data", async () => {
|
|
163
|
+
const client = makeClient();
|
|
164
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
165
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
166
|
+
|
|
167
|
+
await (client as any).handleEvent("job", "totally-not-json");
|
|
168
|
+
expect(handleThreadMessage).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("accepts valid job and calls handleThreadMessage", async () => {
|
|
172
|
+
const client = makeClient();
|
|
173
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
174
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
175
|
+
|
|
176
|
+
await (client as any).handleEvent("job", makeJobEvent());
|
|
177
|
+
expect(handleThreadMessage).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("passes through nested platformMetadata objects", async () => {
|
|
181
|
+
const client = makeClient();
|
|
182
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
183
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
184
|
+
|
|
185
|
+
await (client as any).handleEvent(
|
|
186
|
+
"job",
|
|
187
|
+
makeJobEvent({
|
|
188
|
+
platformMetadata: {
|
|
189
|
+
source: "watcher-run",
|
|
190
|
+
intent: { kind: "watcher_run", runId: 42, watcherId: 7 },
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(handleThreadMessage).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(
|
|
197
|
+
handleThreadMessage.mock.calls[0]?.[0].platformMetadata.intent
|
|
198
|
+
).toEqual({ kind: "watcher_run", runId: 42, watcherId: 7 });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Delivery receipt
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("delivery receipt", () => {
|
|
207
|
+
let originalFetch: typeof globalThis.fetch;
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
originalFetch = globalThis.fetch;
|
|
210
|
+
});
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
globalThis.fetch = originalFetch;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("sends delivery receipt when top-level jobId is present", async () => {
|
|
216
|
+
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
217
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
218
|
+
|
|
219
|
+
const client = makeClient("https://gw.example.com");
|
|
220
|
+
// Stub handleThreadMessage to avoid real worker execution
|
|
221
|
+
(client as any).handleThreadMessage = mock(async () => undefined);
|
|
222
|
+
|
|
223
|
+
const payload = JSON.parse(makeJobEvent());
|
|
224
|
+
payload.jobId = "top-level-job-id";
|
|
225
|
+
|
|
226
|
+
await (client as any).handleEvent("job", JSON.stringify(payload));
|
|
227
|
+
|
|
228
|
+
// Wait a tick for the fire-and-forget receipt fetch
|
|
229
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
230
|
+
|
|
231
|
+
const calls = fetchMock.mock.calls.filter(
|
|
232
|
+
(c) =>
|
|
233
|
+
typeof c[0] === "string" &&
|
|
234
|
+
(c[0] as string).includes("/worker/response")
|
|
235
|
+
);
|
|
236
|
+
expect(calls.length).toBeGreaterThanOrEqual(1);
|
|
237
|
+
const bodyStr = calls[0]?.[1]?.body as string;
|
|
238
|
+
const body = JSON.parse(bodyStr);
|
|
239
|
+
expect(body).toMatchObject({ jobId: "top-level-job-id", received: true });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("does not send delivery receipt when jobId is absent", async () => {
|
|
243
|
+
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
244
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
245
|
+
|
|
246
|
+
const client = makeClient("https://gw.example.com");
|
|
247
|
+
(client as any).handleThreadMessage = mock(async () => undefined);
|
|
248
|
+
|
|
249
|
+
// No top-level jobId
|
|
250
|
+
await (client as any).handleEvent("job", makeJobEvent());
|
|
251
|
+
|
|
252
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
253
|
+
|
|
254
|
+
const receiptCalls = fetchMock.mock.calls.filter(
|
|
255
|
+
(c) =>
|
|
256
|
+
typeof c[0] === "string" &&
|
|
257
|
+
(c[0] as string).includes("/worker/response") &&
|
|
258
|
+
(() => {
|
|
259
|
+
try {
|
|
260
|
+
const b = JSON.parse(c[1]?.body as string);
|
|
261
|
+
return "jobId" in b;
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
})()
|
|
266
|
+
);
|
|
267
|
+
expect(receiptCalls).toHaveLength(0);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Reconnect backoff
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe("handleReconnect exponential backoff", () => {
|
|
276
|
+
test("increments reconnectAttempts and caps delay at 60 s", async () => {
|
|
277
|
+
const client = makeClient();
|
|
278
|
+
|
|
279
|
+
// Spy on setTimeout to capture delay without actually waiting
|
|
280
|
+
const delays: number[] = [];
|
|
281
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
282
|
+
(globalThis as any).setTimeout = (fn: () => void, delay: number) => {
|
|
283
|
+
delays.push(delay);
|
|
284
|
+
// Execute immediately so the await resolves
|
|
285
|
+
fn();
|
|
286
|
+
return 0 as unknown as NodeJS.Timeout;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
// Simulate 4 reconnect cycles (attempt 1-4)
|
|
291
|
+
for (let i = 0; i < 4; i++) {
|
|
292
|
+
(client as any).reconnectAttempts = i;
|
|
293
|
+
await (client as any).handleReconnect();
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
(globalThis as any).setTimeout = originalSetTimeout;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Delays should be: 1000, 2000, 4000, 8000 (2^0, 2^1, 2^2, 2^3 * 1000)
|
|
300
|
+
expect(delays[0]).toBe(1000);
|
|
301
|
+
expect(delays[1]).toBe(2000);
|
|
302
|
+
expect(delays[2]).toBe(4000);
|
|
303
|
+
expect(delays[3]).toBe(8000);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("caps delay at 60000 ms for high attempt numbers", async () => {
|
|
307
|
+
const client = makeClient();
|
|
308
|
+
// maxReconnectAttempts=10; set to 8 so it won't early-return but attempt 9 yields a huge exponent
|
|
309
|
+
(client as any).maxReconnectAttempts = 20;
|
|
310
|
+
|
|
311
|
+
const delays: number[] = [];
|
|
312
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
313
|
+
(globalThis as any).setTimeout = (fn: () => void, delay: number) => {
|
|
314
|
+
delays.push(delay);
|
|
315
|
+
fn();
|
|
316
|
+
return 0 as unknown as NodeJS.Timeout;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
(client as any).reconnectAttempts = 16; // attempt 17 → 2^16 * 1000 = 65536000 → capped at 60000
|
|
321
|
+
await (client as any).handleReconnect();
|
|
322
|
+
} finally {
|
|
323
|
+
(globalThis as any).setTimeout = originalSetTimeout;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
expect(delays[0]).toBe(60000);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("sets isRunning=false and skips delay when max attempts reached", async () => {
|
|
330
|
+
const client = makeClient();
|
|
331
|
+
(client as any).reconnectAttempts = 10; // already at max
|
|
332
|
+
|
|
333
|
+
const delays: number[] = [];
|
|
334
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
335
|
+
(globalThis as any).setTimeout = (fn: () => void, delay: number) => {
|
|
336
|
+
delays.push(delay);
|
|
337
|
+
fn();
|
|
338
|
+
return 0 as unknown as NodeJS.Timeout;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
await (client as any).handleReconnect();
|
|
343
|
+
} finally {
|
|
344
|
+
(globalThis as any).setTimeout = originalSetTimeout;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
expect((client as any).isRunning).toBe(false);
|
|
348
|
+
expect(delays).toHaveLength(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// SSE frame parsing: partial / multi-event chunks
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
describe("SSE partial frame handling (buffer logic)", () => {
|
|
357
|
+
/**
|
|
358
|
+
* Simulate what the connectAndListen loop does with the buffer.
|
|
359
|
+
* We extract that logic here by calling the private parser the same way the
|
|
360
|
+
* loop does: accumulate chunks → split on \n\n → parse fields.
|
|
361
|
+
*/
|
|
362
|
+
function parseSSEChunks(
|
|
363
|
+
chunks: string[]
|
|
364
|
+
): Array<{ eventType: string; eventData: string }> {
|
|
365
|
+
let buffer = "";
|
|
366
|
+
const events: Array<{ eventType: string; eventData: string }> = [];
|
|
367
|
+
|
|
368
|
+
for (const chunk of chunks) {
|
|
369
|
+
buffer += chunk;
|
|
370
|
+
const rawEvents = buffer.split("\n\n");
|
|
371
|
+
buffer = rawEvents.pop() || "";
|
|
372
|
+
|
|
373
|
+
for (const event of rawEvents) {
|
|
374
|
+
if (!event.trim()) continue;
|
|
375
|
+
const lines = event.split("\n");
|
|
376
|
+
let eventType = "message";
|
|
377
|
+
let eventData = "";
|
|
378
|
+
for (const line of lines) {
|
|
379
|
+
if (line.startsWith("event:")) eventType = line.substring(6).trim();
|
|
380
|
+
else if (line.startsWith("data:"))
|
|
381
|
+
eventData = line.substring(5).trim();
|
|
382
|
+
}
|
|
383
|
+
if (eventData) events.push({ eventType, eventData });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return events;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
test("reassembles event split across two chunks", () => {
|
|
391
|
+
const eventJson = JSON.stringify({ ts: 1 });
|
|
392
|
+
const chunk1 = `event: ping\ndata: ${eventJson}`;
|
|
393
|
+
const chunk2 = `\n\n`;
|
|
394
|
+
|
|
395
|
+
const events = parseSSEChunks([chunk1, chunk2]);
|
|
396
|
+
expect(events).toHaveLength(1);
|
|
397
|
+
expect(events[0]).toEqual({ eventType: "ping", eventData: eventJson });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("handles multiple events in a single chunk", () => {
|
|
401
|
+
const chunk = "event: ping\ndata: {}\n\nevent: ping\ndata: {}\n\n";
|
|
402
|
+
|
|
403
|
+
const events = parseSSEChunks([chunk]);
|
|
404
|
+
expect(events).toHaveLength(2);
|
|
405
|
+
expect(events[0]?.eventType).toBe("ping");
|
|
406
|
+
expect(events[1]?.eventType).toBe("ping");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("empty lines between events are skipped", () => {
|
|
410
|
+
const chunk = "\n\nevent: ping\ndata: {}\n\n";
|
|
411
|
+
const events = parseSSEChunks([chunk]);
|
|
412
|
+
expect(events).toHaveLength(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("partial frame split mid-line is reassembled by buffer concatenation", () => {
|
|
416
|
+
// "event: ping\ndat" + "a: {}\n\n" → buffer becomes "event: ping\ndata: {}\n\n"
|
|
417
|
+
// The \n\n delimiter is only present after the second chunk, so one complete event is emitted.
|
|
418
|
+
const events = parseSSEChunks(["event: ping\ndat", "a: {}\n\n"]);
|
|
419
|
+
expect(events).toHaveLength(1);
|
|
420
|
+
expect(events[0]?.eventType).toBe("ping");
|
|
421
|
+
expect(events[0]?.eventData).toBe("{}");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("truly incomplete frame (no trailing double-newline) stays in buffer", () => {
|
|
425
|
+
// No \n\n means the event boundary is never reached → nothing emitted
|
|
426
|
+
const events = parseSSEChunks(["event: ping\ndata: {}"]);
|
|
427
|
+
expect(events).toHaveLength(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("event with only data: field uses default message type", () => {
|
|
431
|
+
const events = parseSSEChunks(['data: {"hello":"world"}\n\n']);
|
|
432
|
+
expect(events).toHaveLength(1);
|
|
433
|
+
expect(events[0]?.eventType).toBe("message");
|
|
434
|
+
expect(events[0]?.eventData).toBe('{"hello":"world"}');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("garbled JSON in data field results in parse error handled by handleEvent", async () => {
|
|
438
|
+
const client = makeClient();
|
|
439
|
+
// handleEvent should catch the JSON parse error internally and not throw
|
|
440
|
+
await expect(
|
|
441
|
+
(client as any).handleEvent("job", "GARBAGE_NOT_JSON")
|
|
442
|
+
).resolves.toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// payloadToWorkerConfig: secret placeholder invariant
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
describe("payloadToWorkerConfig: secret placeholder invariant", () => {
|
|
451
|
+
test("userPrompt is base64-encoded (real creds never travel in plaintext)", () => {
|
|
452
|
+
const client = makeClient();
|
|
453
|
+
const payload = {
|
|
454
|
+
botId: "bot",
|
|
455
|
+
userId: "user-1",
|
|
456
|
+
agentId: "agent-1",
|
|
457
|
+
conversationId: "conv-1",
|
|
458
|
+
platform: "api",
|
|
459
|
+
channelId: "chan-1",
|
|
460
|
+
messageId: "msg-1",
|
|
461
|
+
messageText: "lobu_secret_abc123 should be placeholder, not real key",
|
|
462
|
+
platformMetadata: {},
|
|
463
|
+
agentOptions: {},
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const config = (client as any).payloadToWorkerConfig(payload);
|
|
467
|
+
|
|
468
|
+
// userPrompt is base64 — attempting to decode gives original text,
|
|
469
|
+
// but the field itself must NOT be the raw string
|
|
470
|
+
expect(config.userPrompt).not.toBe(payload.messageText);
|
|
471
|
+
const decoded = Buffer.from(config.userPrompt, "base64").toString("utf-8");
|
|
472
|
+
expect(decoded).toBe(payload.messageText);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a lobu_secret placeholder in messageText is preserved, not stripped", () => {
|
|
476
|
+
const client = makeClient();
|
|
477
|
+
const secretRef = "lobu_secret_d4e5f6a7-b8c9-0000-1111-222233334444";
|
|
478
|
+
const payload = {
|
|
479
|
+
botId: "bot",
|
|
480
|
+
userId: "user-1",
|
|
481
|
+
agentId: "agent-1",
|
|
482
|
+
conversationId: "conv-1",
|
|
483
|
+
platform: "api",
|
|
484
|
+
channelId: "chan-1",
|
|
485
|
+
messageId: "msg-1",
|
|
486
|
+
messageText: `Use the API key: ${secretRef}`,
|
|
487
|
+
platformMetadata: {},
|
|
488
|
+
agentOptions: {},
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const config = (client as any).payloadToWorkerConfig(payload);
|
|
492
|
+
const decoded = Buffer.from(config.userPrompt, "base64").toString("utf-8");
|
|
493
|
+
// Placeholder must be present (the proxy swaps it; worker should see it)
|
|
494
|
+
expect(decoded).toContain(secretRef);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("workerConfig does not contain WORKER_TOKEN or DISPATCHER_URL values", () => {
|
|
498
|
+
const client = makeClient("https://gw.example.com");
|
|
499
|
+
const payload = {
|
|
500
|
+
botId: "bot",
|
|
501
|
+
userId: "user-1",
|
|
502
|
+
agentId: "agent-1",
|
|
503
|
+
conversationId: "conv-1",
|
|
504
|
+
platform: "api",
|
|
505
|
+
channelId: "chan-1",
|
|
506
|
+
messageId: "msg-1",
|
|
507
|
+
messageText: "hello",
|
|
508
|
+
platformMetadata: {},
|
|
509
|
+
agentOptions: {},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const config = (client as any).payloadToWorkerConfig(payload);
|
|
513
|
+
const configStr = JSON.stringify(config);
|
|
514
|
+
|
|
515
|
+
// The GatewayClient reads WORKER_TOKEN from its constructor arg, not env.
|
|
516
|
+
// The worker config should never carry the raw token string.
|
|
517
|
+
expect(configStr).not.toContain("test-token");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("agentOptions are serialised as JSON string in workerConfig", () => {
|
|
521
|
+
const client = makeClient();
|
|
522
|
+
const agentOptions = {
|
|
523
|
+
model: "anthropic/claude-sonnet-4-20250514",
|
|
524
|
+
maxTokens: 4096,
|
|
525
|
+
};
|
|
526
|
+
const payload = {
|
|
527
|
+
botId: "bot",
|
|
528
|
+
userId: "user-1",
|
|
529
|
+
agentId: "agent-1",
|
|
530
|
+
conversationId: "conv-1",
|
|
531
|
+
platform: "api",
|
|
532
|
+
channelId: "chan-1",
|
|
533
|
+
messageId: "msg-1",
|
|
534
|
+
messageText: "hi",
|
|
535
|
+
platformMetadata: {},
|
|
536
|
+
agentOptions,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const config = (client as any).payloadToWorkerConfig(payload);
|
|
540
|
+
expect(typeof config.agentOptions).toBe("string");
|
|
541
|
+
const parsed = JSON.parse(config.agentOptions);
|
|
542
|
+
expect(parsed.model).toBe("anthropic/claude-sonnet-4-20250514");
|
|
543
|
+
expect(parsed.maxTokens).toBe(4096);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// getStatus / isHealthy
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
describe("getStatus and isHealthy", () => {
|
|
552
|
+
test("getStatus reports isRunning=false before start()", () => {
|
|
553
|
+
const client = makeClient();
|
|
554
|
+
const status = client.getStatus();
|
|
555
|
+
expect(status.isRunning).toBe(false);
|
|
556
|
+
expect(status.userId).toBe("user-1");
|
|
557
|
+
expect(status.deploymentName).toBe("worker-1");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("isHealthy returns false when not running", () => {
|
|
561
|
+
const client = makeClient();
|
|
562
|
+
expect(client.isHealthy()).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// stop() cleans up current worker
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
describe("stop() mid-run cleanup", () => {
|
|
571
|
+
test("stop() calls cleanup() on a running worker and nullifies it", async () => {
|
|
572
|
+
const client = makeClient();
|
|
573
|
+
|
|
574
|
+
const cleanupMock = mock(async () => undefined);
|
|
575
|
+
(client as any).currentWorker = { cleanup: cleanupMock };
|
|
576
|
+
|
|
577
|
+
await client.stop();
|
|
578
|
+
|
|
579
|
+
expect(cleanupMock).toHaveBeenCalledTimes(1);
|
|
580
|
+
expect((client as any).currentWorker).toBeNull();
|
|
581
|
+
expect((client as any).isRunning).toBe(false);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("stop() without a running worker does not throw", async () => {
|
|
585
|
+
const client = makeClient();
|
|
586
|
+
await expect(client.stop()).resolves.toBeUndefined();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { GatewayClient } from "../gateway/sse-client";
|
|
3
|
+
|
|
4
|
+
describe("GatewayClient heartbeat ACKs", () => {
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.fetch = originalFetch;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("accepts nested platform metadata on job events", async () => {
|
|
12
|
+
const client = new GatewayClient(
|
|
13
|
+
"https://gateway.example.com",
|
|
14
|
+
"worker-token",
|
|
15
|
+
"user-1",
|
|
16
|
+
"worker-1"
|
|
17
|
+
);
|
|
18
|
+
const handleThreadMessage = mock(async () => undefined);
|
|
19
|
+
(client as any).handleThreadMessage = handleThreadMessage;
|
|
20
|
+
|
|
21
|
+
await (client as any).handleEvent(
|
|
22
|
+
"job",
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
payload: {
|
|
25
|
+
botId: "lobu-api",
|
|
26
|
+
userId: "watcher_218",
|
|
27
|
+
agentId: "marketing",
|
|
28
|
+
conversationId: "marketing_watcher_218_run_120947",
|
|
29
|
+
platform: "api",
|
|
30
|
+
channelId: "api_watcher_218",
|
|
31
|
+
messageId: "message-1",
|
|
32
|
+
messageText: "run watcher",
|
|
33
|
+
platformMetadata: {
|
|
34
|
+
agentId: "marketing",
|
|
35
|
+
source: "watcher-run",
|
|
36
|
+
intent: { kind: "watcher_run", runId: 120947, watcherId: 218 },
|
|
37
|
+
},
|
|
38
|
+
agentOptions: {},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(handleThreadMessage).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(
|
|
45
|
+
handleThreadMessage.mock.calls[0]?.[0].platformMetadata.intent
|
|
46
|
+
).toEqual({
|
|
47
|
+
kind: "watcher_run",
|
|
48
|
+
runId: 120947,
|
|
49
|
+
watcherId: 218,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("ACKs heartbeat pings over the worker response endpoint", async () => {
|
|
54
|
+
const fetchMock = mock(
|
|
55
|
+
async (_url: string | URL | Request, _options?: RequestInit) =>
|
|
56
|
+
new Response(JSON.stringify({ success: true }), {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
62
|
+
|
|
63
|
+
const client = new GatewayClient(
|
|
64
|
+
"https://gateway.example.com",
|
|
65
|
+
"worker-token",
|
|
66
|
+
"user-1",
|
|
67
|
+
"worker-1"
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await (client as any).handleEvent(
|
|
71
|
+
"ping",
|
|
72
|
+
JSON.stringify({ timestamp: Date.now() })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
77
|
+
"https://gateway.example.com/worker/response"
|
|
78
|
+
);
|
|
79
|
+
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
Authorization: "Bearer worker-token",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(
|
|
87
|
+
JSON.stringify({ received: true, heartbeat: true })
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|