@botcord/daemon 0.2.35 → 0.2.37

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 (68) hide show
  1. package/dist/config.d.ts +30 -1
  2. package/dist/config.js +27 -0
  3. package/dist/daemon-config-map.d.ts +3 -0
  4. package/dist/daemon-config-map.js +30 -0
  5. package/dist/daemon.d.ts +15 -1
  6. package/dist/daemon.js +56 -11
  7. package/dist/gateway/channels/botcord.js +44 -0
  8. package/dist/gateway/channels/http-types.d.ts +19 -0
  9. package/dist/gateway/channels/http-types.js +1 -0
  10. package/dist/gateway/channels/index.d.ts +5 -0
  11. package/dist/gateway/channels/index.js +5 -0
  12. package/dist/gateway/channels/login-session.d.ts +83 -0
  13. package/dist/gateway/channels/login-session.js +99 -0
  14. package/dist/gateway/channels/secret-store.d.ts +21 -0
  15. package/dist/gateway/channels/secret-store.js +75 -0
  16. package/dist/gateway/channels/state-store.d.ts +60 -0
  17. package/dist/gateway/channels/state-store.js +173 -0
  18. package/dist/gateway/channels/telegram.d.ts +31 -0
  19. package/dist/gateway/channels/telegram.js +371 -0
  20. package/dist/gateway/channels/text-split.d.ts +13 -0
  21. package/dist/gateway/channels/text-split.js +33 -0
  22. package/dist/gateway/channels/url-guard.d.ts +18 -0
  23. package/dist/gateway/channels/url-guard.js +53 -0
  24. package/dist/gateway/channels/wechat-http.d.ts +18 -0
  25. package/dist/gateway/channels/wechat-http.js +28 -0
  26. package/dist/gateway/channels/wechat-login.d.ts +36 -0
  27. package/dist/gateway/channels/wechat-login.js +62 -0
  28. package/dist/gateway/channels/wechat.d.ts +40 -0
  29. package/dist/gateway/channels/wechat.js +472 -0
  30. package/dist/gateway/runtimes/openclaw-acp.js +211 -6
  31. package/dist/gateway/types.d.ts +10 -0
  32. package/dist/gateway-control.d.ts +53 -0
  33. package/dist/gateway-control.js +638 -0
  34. package/dist/openclaw-discovery.js +1 -1
  35. package/dist/provision.d.ts +7 -0
  36. package/dist/provision.js +255 -5
  37. package/package.json +1 -1
  38. package/src/__tests__/gateway-control.test.ts +499 -0
  39. package/src/__tests__/openclaw-acp.test.ts +63 -0
  40. package/src/__tests__/openclaw-discovery.test.ts +36 -0
  41. package/src/__tests__/provision.test.ts +179 -0
  42. package/src/__tests__/secret-store.test.ts +70 -0
  43. package/src/__tests__/state-store.test.ts +119 -0
  44. package/src/__tests__/third-party-gateway.test.ts +126 -0
  45. package/src/__tests__/url-guard.test.ts +85 -0
  46. package/src/__tests__/wechat-channel.test.ts +1134 -0
  47. package/src/config.ts +72 -1
  48. package/src/daemon-config-map.ts +24 -0
  49. package/src/daemon.ts +70 -11
  50. package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
  51. package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
  52. package/src/gateway/channels/botcord.ts +39 -0
  53. package/src/gateway/channels/http-types.ts +22 -0
  54. package/src/gateway/channels/index.ts +22 -0
  55. package/src/gateway/channels/login-session.ts +135 -0
  56. package/src/gateway/channels/secret-store.ts +100 -0
  57. package/src/gateway/channels/state-store.ts +213 -0
  58. package/src/gateway/channels/telegram.ts +469 -0
  59. package/src/gateway/channels/text-split.ts +29 -0
  60. package/src/gateway/channels/url-guard.ts +55 -0
  61. package/src/gateway/channels/wechat-http.ts +35 -0
  62. package/src/gateway/channels/wechat-login.ts +90 -0
  63. package/src/gateway/channels/wechat.ts +572 -0
  64. package/src/gateway/runtimes/openclaw-acp.ts +211 -7
  65. package/src/gateway/types.ts +10 -0
  66. package/src/gateway-control.ts +709 -0
  67. package/src/openclaw-discovery.ts +1 -1
  68. package/src/provision.ts +336 -5
@@ -0,0 +1,499 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import type { DaemonConfig } from "../config.js";
7
+ import { LoginSessionStore } from "../gateway/channels/login-session.js";
8
+ import { createGatewayControl } from "../gateway-control.js";
9
+
10
+ // `secret-store.ts` resolves `DEFAULT_GATEWAYS_DIR` via `homedir()` at module
11
+ // load, so a `process.env.HOME` override in `beforeEach` doesn't redirect
12
+ // writes — we have to clean up under the real home instead.
13
+ const realGatewaysDir = path.join(homedir(), ".botcord", "daemon", "gateways");
14
+ const trackedSecrets = new Set<string>();
15
+
16
+ function trackSecret(id: string): string {
17
+ const p = path.join(realGatewaysDir, `${id}.json`);
18
+ trackedSecrets.add(p);
19
+ return p;
20
+ }
21
+
22
+ // Use a unique gateway-id prefix per test run so concurrent vitest workers
23
+ // don't trample each other's secret files.
24
+ const TEST_RUN_ID = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
25
+ let testCounter = 0;
26
+ function uniqId(label: string): string {
27
+ testCounter += 1;
28
+ return `gw_${label}_${TEST_RUN_ID}_${testCounter}`;
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const p of trackedSecrets) {
33
+ try {
34
+ unlinkSync(p);
35
+ } catch {
36
+ // best-effort
37
+ }
38
+ }
39
+ trackedSecrets.clear();
40
+ });
41
+
42
+ interface FakeGateway {
43
+ channels: Map<string, { id: string; status: Record<string, unknown> }>;
44
+ addChannel: ReturnType<typeof vi.fn>;
45
+ removeChannel: ReturnType<typeof vi.fn>;
46
+ snapshot: () => { channels: Record<string, any>; turns: Record<string, any> };
47
+ }
48
+
49
+ function makeFakeGateway(): FakeGateway {
50
+ const channels = new Map<string, { id: string; status: Record<string, unknown> }>();
51
+ return {
52
+ channels,
53
+ addChannel: vi.fn(async (cfg: { id: string; accountId: string }) => {
54
+ channels.set(cfg.id, {
55
+ id: cfg.id,
56
+ status: {
57
+ channel: cfg.id,
58
+ accountId: cfg.accountId,
59
+ running: true,
60
+ connected: true,
61
+ authorized: true,
62
+ lastPollAt: Date.now(),
63
+ },
64
+ });
65
+ }),
66
+ removeChannel: vi.fn(async (id: string) => {
67
+ channels.delete(id);
68
+ }),
69
+ snapshot: () => ({
70
+ channels: Object.fromEntries([...channels].map(([id, e]) => [id, e.status])),
71
+ turns: {},
72
+ }),
73
+ };
74
+ }
75
+
76
+ function makeConfigIO(initial: DaemonConfig) {
77
+ const state = { cfg: JSON.parse(JSON.stringify(initial)) as DaemonConfig };
78
+ return {
79
+ state,
80
+ io: {
81
+ load: () => JSON.parse(JSON.stringify(state.cfg)) as DaemonConfig,
82
+ save: (next: DaemonConfig) => {
83
+ state.cfg = JSON.parse(JSON.stringify(next)) as DaemonConfig;
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ const baseCfg = (): DaemonConfig => ({
90
+ agents: ["ag_alice"],
91
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
92
+ routes: [],
93
+ streamBlocks: false,
94
+ });
95
+
96
+ describe("upsert_gateway", () => {
97
+ it("(a) telegram secret writes file with mode 0600 and hot-plugs the channel", async () => {
98
+ const gw = makeFakeGateway();
99
+ const { state, io } = makeConfigIO(baseCfg());
100
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
101
+ const gwId = uniqId("tg");
102
+
103
+ const ack = await ctrl.handleUpsert({
104
+ id: gwId,
105
+ type: "telegram",
106
+ accountId: "ag_alice",
107
+ label: "My TG",
108
+ enabled: true,
109
+ secret: { botToken: "111:abcdefghijklmnop" },
110
+ settings: { allowedChatIds: ["123"] },
111
+ });
112
+
113
+ expect(ack.ok).toBe(true);
114
+ const result = ack.result as { id: string; tokenPreview: string; status?: any };
115
+ expect(result.id).toBe(gwId);
116
+ expect(result.tokenPreview).toBe("111:...mnop");
117
+
118
+ const secretPath = trackSecret(gwId);
119
+ expect(existsSync(secretPath)).toBe(true);
120
+ const mode = statSync(secretPath).mode & 0o777;
121
+ expect(mode).toBe(0o600);
122
+ const onDisk = JSON.parse(readFileSync(secretPath, "utf8"));
123
+ expect(onDisk.botToken).toBe("111:abcdefghijklmnop");
124
+
125
+ expect(gw.addChannel).toHaveBeenCalledOnce();
126
+ expect(gw.channels.has(gwId)).toBe(true);
127
+
128
+ expect(state.cfg.thirdPartyGateways).toHaveLength(1);
129
+ expect(state.cfg.thirdPartyGateways![0].id).toBe(gwId);
130
+ expect(state.cfg.thirdPartyGateways![0].label).toBe("My TG");
131
+ });
132
+
133
+ it("(b) wechat upsert with mismatched accountId is rejected", async () => {
134
+ const gw = makeFakeGateway();
135
+ const { io } = makeConfigIO(baseCfg());
136
+ const sessions = new LoginSessionStore();
137
+ sessions.create({
138
+ loginId: "wxl_1",
139
+ accountId: "ag_other",
140
+ provider: "wechat",
141
+ qrcode: "QR",
142
+ baseUrl: "https://ilinkai.weixin.qq.com",
143
+ botToken: "wechat-bot-token-1234",
144
+ });
145
+ const ctrl = createGatewayControl({
146
+ gateway: gw as any,
147
+ configIO: io,
148
+ loginSessions: sessions,
149
+ });
150
+
151
+ const ack = await ctrl.handleUpsert({
152
+ id: "gw_wx_1",
153
+ type: "wechat",
154
+ accountId: "ag_alice",
155
+ enabled: true,
156
+ loginId: "wxl_1",
157
+ });
158
+
159
+ expect(ack.ok).toBe(false);
160
+ expect(ack.error?.code).toBe("login_account_mismatch");
161
+ expect(gw.addChannel).not.toHaveBeenCalled();
162
+ });
163
+ });
164
+
165
+ describe("remove_gateway", () => {
166
+ it("(c) deletes secret file and removes channel + config entry", async () => {
167
+ const gw = makeFakeGateway();
168
+ const gwId = uniqId("rm");
169
+ const { state, io } = makeConfigIO(baseCfg());
170
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
171
+ await ctrl.handleUpsert({
172
+ id: gwId,
173
+ type: "telegram",
174
+ accountId: "ag_alice",
175
+ enabled: true,
176
+ secret: { botToken: "111:abcdefghijklmnop" },
177
+ });
178
+ const secretPath = trackSecret(gwId);
179
+ expect(existsSync(secretPath)).toBe(true);
180
+ expect(gw.channels.has(gwId)).toBe(true);
181
+
182
+ const ack = await ctrl.handleRemove({ id: gwId });
183
+ expect(ack.ok).toBe(true);
184
+ const result = ack.result as { removed: boolean; secretDeleted: boolean };
185
+ expect(result.removed).toBe(true);
186
+ expect(result.secretDeleted).toBe(true);
187
+ expect(existsSync(secretPath)).toBe(false);
188
+ expect(gw.channels.has(gwId)).toBe(false);
189
+ expect(state.cfg.thirdPartyGateways ?? []).toHaveLength(0);
190
+ });
191
+ });
192
+
193
+ describe("gateway_login_start / status", () => {
194
+ it("(d) round-trip with mocked iLink fetch returns confirmed + tokenPreview, never the bot token", async () => {
195
+ const gw = makeFakeGateway();
196
+ const { io } = makeConfigIO(baseCfg());
197
+ const sessions = new LoginSessionStore();
198
+ const wechatLogin = {
199
+ getBotQrcode: vi.fn(async () => ({
200
+ qrcode: "QR-OPAQUE",
201
+ qrcodeUrl: "https://example/qr.png",
202
+ raw: {},
203
+ })),
204
+ getQrcodeStatus: vi.fn(async () => ({
205
+ status: "confirmed",
206
+ botToken: "wechat-bot-token-1234567890",
207
+ baseUrl: "https://ilinkai.weixin.qq.com",
208
+ raw: {},
209
+ })),
210
+ };
211
+ const ctrl = createGatewayControl({
212
+ gateway: gw as any,
213
+ configIO: io,
214
+ loginSessions: sessions,
215
+ wechatLoginClient: wechatLogin,
216
+ });
217
+
218
+ const startAck = await ctrl.handleLoginStart({
219
+ provider: "wechat",
220
+ accountId: "ag_alice",
221
+ });
222
+ expect(startAck.ok).toBe(true);
223
+ const startResult = startAck.result as { loginId: string; qrcode: string; qrcodeUrl?: string; expiresAt: number };
224
+ expect(startResult.loginId).toMatch(/^wxl_/);
225
+ expect(startResult.qrcode).toBe("QR-OPAQUE");
226
+ expect(startResult.qrcodeUrl).toBe("https://example/qr.png");
227
+ expect(startResult.expiresAt).toBeGreaterThan(Date.now());
228
+
229
+ const statusAck = await ctrl.handleLoginStatus({
230
+ provider: "wechat",
231
+ loginId: startResult.loginId,
232
+ accountId: "ag_alice",
233
+ });
234
+ expect(statusAck.ok).toBe(true);
235
+ const statusResult = statusAck.result as { status: string; tokenPreview?: string; baseUrl?: string };
236
+ expect(statusResult.status).toBe("confirmed");
237
+ expect(statusResult.tokenPreview).toBe("wech...7890");
238
+ expect(statusResult.baseUrl).toBe("https://ilinkai.weixin.qq.com");
239
+ // Bot token never escapes the daemon.
240
+ expect(JSON.stringify(statusResult)).not.toContain("wechat-bot-token-1234567890");
241
+ });
242
+ });
243
+
244
+ describe("frame schema validation", () => {
245
+ it("(e) login_start rejects unknown provider", async () => {
246
+ const gw = makeFakeGateway();
247
+ const { io } = makeConfigIO(baseCfg());
248
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
249
+
250
+ const ack = await ctrl.handleLoginStart({
251
+ // @ts-expect-error — exercising the runtime guard
252
+ provider: "line",
253
+ accountId: "ag_alice",
254
+ });
255
+ expect(ack.ok).toBe(false);
256
+ expect(ack.error?.code).toBe("bad_params");
257
+ expect(ack.error?.message).toContain("unknown provider");
258
+ });
259
+
260
+ it("upsert rejects unknown provider", async () => {
261
+ const gw = makeFakeGateway();
262
+ const { io } = makeConfigIO(baseCfg());
263
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
264
+
265
+ const ack = await ctrl.handleUpsert({
266
+ id: "gw_x",
267
+ // @ts-expect-error — exercising the runtime guard
268
+ type: "discord",
269
+ accountId: "ag_alice",
270
+ enabled: true,
271
+ });
272
+ expect(ack.ok).toBe(false);
273
+ expect(ack.error?.code).toBe("bad_params");
274
+ });
275
+ });
276
+
277
+ describe("C3: empty agent set rejects upsert", () => {
278
+ it("upsert against a daemon with no provisioned agents is rejected with unknown_account", async () => {
279
+ const gw = makeFakeGateway();
280
+ const { io } = makeConfigIO({
281
+ // No `agents`, no legacy `agentId` -> resolveConfiguredAgentIds returns null.
282
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
283
+ routes: [],
284
+ streamBlocks: false,
285
+ });
286
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
287
+
288
+ const ack = await ctrl.handleUpsert({
289
+ id: "gw_should_fail",
290
+ type: "telegram",
291
+ accountId: "ag_unprovisioned",
292
+ enabled: true,
293
+ secret: { botToken: "111:abcdefghijklmnop" },
294
+ });
295
+
296
+ expect(ack.ok).toBe(false);
297
+ expect(ack.error?.code).toBe("unknown_account");
298
+ expect(gw.addChannel).not.toHaveBeenCalled();
299
+ });
300
+ });
301
+
302
+ describe("W3: orphan secret cleanup on addChannel failure", () => {
303
+ it("deletes the secret on the way out when addChannel fails for a fresh install", async () => {
304
+ const gw = makeFakeGateway();
305
+ gw.addChannel = vi.fn(async () => {
306
+ throw new Error("simulated boom");
307
+ });
308
+ const { io } = makeConfigIO(baseCfg());
309
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
310
+ const gwId = uniqId("orphan");
311
+
312
+ const ack = await ctrl.handleUpsert({
313
+ id: gwId,
314
+ type: "telegram",
315
+ accountId: "ag_alice",
316
+ enabled: true,
317
+ secret: { botToken: "111:abcdefghijklmnop" },
318
+ });
319
+ expect(ack.ok).toBe(false);
320
+ expect(ack.error?.code).toBe("addChannel_failed");
321
+ // Secret file must NOT linger on disk.
322
+ const secretPath = trackSecret(gwId);
323
+ expect(existsSync(secretPath)).toBe(false);
324
+ });
325
+ });
326
+
327
+ describe("W6: remove_gateway keeps secret when stop fails", () => {
328
+ it("re-throws on removeChannel failure and does NOT delete the secret", async () => {
329
+ const gw = makeFakeGateway();
330
+ const { io } = makeConfigIO(baseCfg());
331
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
332
+ const gwId = uniqId("rmkeep");
333
+
334
+ // First, install successfully.
335
+ await ctrl.handleUpsert({
336
+ id: gwId,
337
+ type: "telegram",
338
+ accountId: "ag_alice",
339
+ enabled: true,
340
+ secret: { botToken: "111:abcdefghijklmnop" },
341
+ });
342
+ const secretPath = trackSecret(gwId);
343
+ expect(existsSync(secretPath)).toBe(true);
344
+
345
+ // Now make removeChannel throw.
346
+ gw.removeChannel = vi.fn(async () => {
347
+ throw new Error("stop failed");
348
+ });
349
+
350
+ const ack = await ctrl.handleRemove({ id: gwId });
351
+ expect(ack.ok).toBe(false);
352
+ expect(ack.error?.code).toBe("removeChannel_failed");
353
+ // Secret survives — operator can retry without re-issuing the token.
354
+ expect(existsSync(secretPath)).toBe(true);
355
+ });
356
+ });
357
+
358
+ describe("W6: UPDATE rollback on addChannel failure", () => {
359
+ it("restores previous secret and config and re-adds old channel when addChannel fails on UPDATE", async () => {
360
+ const gw = makeFakeGateway();
361
+ const { state, io } = makeConfigIO(baseCfg());
362
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
363
+ const gwId = uniqId("w6");
364
+ trackSecret(gwId);
365
+
366
+ // Install successfully with initial token.
367
+ const firstAck = await ctrl.handleUpsert({
368
+ id: gwId,
369
+ type: "telegram",
370
+ accountId: "ag_alice",
371
+ enabled: true,
372
+ secret: { botToken: "old-token:123456789012345" },
373
+ });
374
+ expect(firstAck.ok).toBe(true);
375
+ expect(gw.addChannel).toHaveBeenCalledTimes(1);
376
+
377
+ // Capture current config before UPDATE.
378
+ const prevProfile = state.cfg.thirdPartyGateways?.find((g) => g.id === gwId);
379
+ expect(prevProfile).toBeDefined();
380
+
381
+ // Make addChannel fail on the next call (the UPDATE attempt).
382
+ let addCallCount = 0;
383
+ gw.addChannel = vi.fn(async (cfg: { id: string; accountId: string }) => {
384
+ addCallCount += 1;
385
+ if (addCallCount === 1) {
386
+ // First call after UPDATE: fail.
387
+ throw new Error("simulated update failure");
388
+ }
389
+ // Second call: the rollback re-add — succeed.
390
+ gw.channels.set(cfg.id, {
391
+ id: cfg.id,
392
+ status: { channel: cfg.id, accountId: cfg.accountId, running: true, connected: true, authorized: true, lastPollAt: Date.now() },
393
+ });
394
+ });
395
+
396
+ // Attempt UPDATE with a new token.
397
+ const updateAck = await ctrl.handleUpsert({
398
+ id: gwId,
399
+ type: "telegram",
400
+ accountId: "ag_alice",
401
+ enabled: true,
402
+ secret: { botToken: "new-token:AAAAAAAAAAAAAAAAA" },
403
+ });
404
+ // The outer result is still a failure (addChannel failed).
405
+ expect(updateAck.ok).toBe(false);
406
+ expect(updateAck.error?.code).toBe("addChannel_failed");
407
+
408
+ // Rollback: addChannel was called twice (fail + restore).
409
+ expect(addCallCount).toBe(2);
410
+
411
+ // Secret on disk must be restored to the old token.
412
+ const secretPath = trackSecret(gwId);
413
+ const { existsSync: ex, readFileSync: rf } = await import("node:fs");
414
+ if (ex(secretPath)) {
415
+ const onDisk = JSON.parse(rf(secretPath, "utf8")) as { botToken?: string };
416
+ // Restored secret should be the old token, not the new one.
417
+ expect(onDisk.botToken).toBe("old-token:123456789012345");
418
+ }
419
+ });
420
+ });
421
+
422
+ describe("list_gateways", () => {
423
+ it("returns config entries annotated with live channel status", async () => {
424
+ const gw = makeFakeGateway();
425
+ const { io } = makeConfigIO(baseCfg());
426
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
427
+
428
+ const gwId = uniqId("ls");
429
+ trackSecret(gwId);
430
+ await ctrl.handleUpsert({
431
+ id: gwId,
432
+ type: "telegram",
433
+ accountId: "ag_alice",
434
+ enabled: true,
435
+ secret: { botToken: "111:abcdefghijklmnop" },
436
+ settings: { allowedChatIds: ["c1"] },
437
+ label: "TG",
438
+ });
439
+
440
+ const ack = ctrl.handleList();
441
+ expect(ack.ok).toBe(true);
442
+ const result = ack.result as { gateways: Array<any> };
443
+ expect(result.gateways).toHaveLength(1);
444
+ const g = result.gateways[0];
445
+ expect(g.id).toBe(gwId);
446
+ expect(g.type).toBe("telegram");
447
+ expect(g.label).toBe("TG");
448
+ expect(g.allowedChatIds).toEqual(["c1"]);
449
+ expect(g.enabled).toBe(true);
450
+ expect(g.status?.running).toBe(true);
451
+ expect(g.status?.authorized).toBe(true);
452
+ });
453
+ });
454
+
455
+ describe("W4: handleLoginStatus accountId ownership check", () => {
456
+ it("returns forbidden when accountId does not match the login session", async () => {
457
+ const gw = makeFakeGateway();
458
+ const { io } = makeConfigIO(baseCfg());
459
+ const sessions = new LoginSessionStore();
460
+ const wechatLogin = {
461
+ getBotQrcode: vi.fn(async () => ({ qrcode: "QR", qrcodeUrl: undefined, raw: {} })),
462
+ getQrcodeStatus: vi.fn(async () => ({ status: "pending", raw: {} })),
463
+ };
464
+ const ctrl = createGatewayControl({
465
+ gateway: gw as any,
466
+ configIO: io,
467
+ loginSessions: sessions,
468
+ wechatLoginClient: wechatLogin,
469
+ });
470
+
471
+ const startAck = await ctrl.handleLoginStart({ provider: "wechat", accountId: "ag_alice" });
472
+ expect(startAck.ok).toBe(true);
473
+ const loginId = (startAck.result as { loginId: string }).loginId;
474
+
475
+ // Poll with a different accountId — must be rejected.
476
+ const ack = await ctrl.handleLoginStatus({
477
+ provider: "wechat",
478
+ loginId,
479
+ accountId: "ag_eve",
480
+ });
481
+ expect(ack.ok).toBe(false);
482
+ expect(ack.error?.code).toBe("forbidden");
483
+ });
484
+
485
+ it("missing accountId returns bad_params", async () => {
486
+ const gw = makeFakeGateway();
487
+ const { io } = makeConfigIO(baseCfg());
488
+ const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
489
+
490
+ const ack = await ctrl.handleLoginStatus({
491
+ provider: "wechat",
492
+ loginId: "wxl_fake",
493
+ // @ts-expect-error — exercising the runtime guard
494
+ accountId: undefined,
495
+ });
496
+ expect(ack.ok).toBe(false);
497
+ expect(ack.error?.code).toBe("bad_params");
498
+ });
499
+ });
@@ -162,6 +162,69 @@ describe("OpenclawAcpAdapter.run", () => {
162
162
  expect(spawnFn.mock.calls[0][1]).toEqual(["acp", "--url", "ws://127.0.0.1:1"]);
163
163
  });
164
164
 
165
+ it("streams only final text when OpenClaw sends reasoning before a final block", async () => {
166
+ const child = new FakeChild();
167
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
168
+ const gateway: ResolvedOpenclawGateway = {
169
+ name: "local",
170
+ url: "ws://127.0.0.1:1",
171
+ openclawAgent: "main",
172
+ };
173
+
174
+ child.stdin.on("data", (chunk: Buffer) => {
175
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
176
+ const frame = JSON.parse(line);
177
+ if (frame.method === "initialize") {
178
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
179
+ } else if (frame.method === "session/new") {
180
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-final" } }) + "\n");
181
+ } else if (frame.method === "session/prompt") {
182
+ for (const text of [
183
+ "The user is asking for my location. I need to check it. ",
184
+ "<fin",
185
+ "al>The answer is Council Bluffs.",
186
+ "</final>",
187
+ ]) {
188
+ child.stdout.write(
189
+ JSON.stringify({
190
+ jsonrpc: "2.0",
191
+ method: "session/update",
192
+ params: {
193
+ sessionId: "sid-final",
194
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
195
+ },
196
+ }) + "\n",
197
+ );
198
+ }
199
+ child.stdout.write(
200
+ JSON.stringify({
201
+ jsonrpc: "2.0",
202
+ id: frame.id,
203
+ result: { text: "The user is asking for my location. I need to check it." },
204
+ }) + "\n",
205
+ );
206
+ }
207
+ }
208
+ });
209
+
210
+ const blocks: any[] = [];
211
+ const res = await adapter.run({
212
+ text: "what's your current location",
213
+ sessionId: null,
214
+ cwd: "/tmp",
215
+ accountId: "ag_alice",
216
+ signal: new AbortController().signal,
217
+ trustLevel: "owner",
218
+ gateway,
219
+ onBlock: (b) => blocks.push(b),
220
+ });
221
+
222
+ expect(res.text).toBe("The answer is Council Bluffs.");
223
+ expect(blocks.filter((b) => b.kind === "assistant_text").map((b) => b.raw.params.update.content[0].text).join("")).toBe(
224
+ "The answer is Council Bluffs.",
225
+ );
226
+ });
227
+
165
228
  it("respawns the pooled child when gateway.url or gateway.token changes under the same name", async () => {
166
229
  function newChild(): FakeChild {
167
230
  const c = new FakeChild();
@@ -8,6 +8,8 @@ import {
8
8
  defaultOpenclawDiscoveryTokenFilePaths,
9
9
  discoverLocalOpenclawGateways,
10
10
  mergeOpenclawGateways,
11
+ openclawAutoProvisionEnabled,
12
+ openclawDiscoveryConfigEnabled,
11
13
  } from "../openclaw-discovery.js";
12
14
  import type { DaemonConfig } from "../config.js";
13
15
  import type { WsEndpointProbeFn } from "../provision.js";
@@ -32,6 +34,40 @@ function baseConfig(): DaemonConfig {
32
34
  };
33
35
  }
34
36
 
37
+ describe("openclaw discovery config flags", () => {
38
+ it("keeps gateway discovery enabled by default", () => {
39
+ expect(openclawDiscoveryConfigEnabled(baseConfig())).toBe(true);
40
+ expect(
41
+ openclawDiscoveryConfigEnabled({
42
+ ...baseConfig(),
43
+ openclawDiscovery: { enabled: false },
44
+ }),
45
+ ).toBe(false);
46
+ });
47
+
48
+ it("requires explicit opt-in for OpenClaw auto-provision", () => {
49
+ expect(openclawAutoProvisionEnabled(baseConfig())).toBe(false);
50
+ expect(
51
+ openclawAutoProvisionEnabled({
52
+ ...baseConfig(),
53
+ openclawDiscovery: {},
54
+ }),
55
+ ).toBe(false);
56
+ expect(
57
+ openclawAutoProvisionEnabled({
58
+ ...baseConfig(),
59
+ openclawDiscovery: { autoProvision: true },
60
+ }),
61
+ ).toBe(true);
62
+ expect(
63
+ openclawAutoProvisionEnabled({
64
+ ...baseConfig(),
65
+ openclawDiscovery: { autoProvision: false },
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+ });
70
+
35
71
  describe("discoverLocalOpenclawGateways", () => {
36
72
  it("discovers JSON and TOML acp config files", async () => {
37
73
  const dir = tempDir();