@botcord/daemon 0.2.36 → 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 (65) hide show
  1. package/dist/config.d.ts +29 -0
  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/provision.d.ts +7 -0
  35. package/dist/provision.js +255 -5
  36. package/package.json +1 -1
  37. package/src/__tests__/gateway-control.test.ts +499 -0
  38. package/src/__tests__/openclaw-acp.test.ts +63 -0
  39. package/src/__tests__/provision.test.ts +179 -0
  40. package/src/__tests__/secret-store.test.ts +70 -0
  41. package/src/__tests__/state-store.test.ts +119 -0
  42. package/src/__tests__/third-party-gateway.test.ts +126 -0
  43. package/src/__tests__/url-guard.test.ts +85 -0
  44. package/src/__tests__/wechat-channel.test.ts +1134 -0
  45. package/src/config.ts +71 -0
  46. package/src/daemon-config-map.ts +24 -0
  47. package/src/daemon.ts +70 -11
  48. package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
  49. package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
  50. package/src/gateway/channels/botcord.ts +39 -0
  51. package/src/gateway/channels/http-types.ts +22 -0
  52. package/src/gateway/channels/index.ts +22 -0
  53. package/src/gateway/channels/login-session.ts +135 -0
  54. package/src/gateway/channels/secret-store.ts +100 -0
  55. package/src/gateway/channels/state-store.ts +213 -0
  56. package/src/gateway/channels/telegram.ts +469 -0
  57. package/src/gateway/channels/text-split.ts +29 -0
  58. package/src/gateway/channels/url-guard.ts +55 -0
  59. package/src/gateway/channels/wechat-http.ts +35 -0
  60. package/src/gateway/channels/wechat-login.ts +90 -0
  61. package/src/gateway/channels/wechat.ts +572 -0
  62. package/src/gateway/runtimes/openclaw-acp.ts +211 -7
  63. package/src/gateway/types.ts +10 -0
  64. package/src/gateway-control.ts +709 -0
  65. package/src/provision.ts +336 -5
@@ -145,6 +145,110 @@ function makeFakeGateway(initialChannelIds: string[] = []): FakeGateway {
145
145
  };
146
146
  }
147
147
 
148
+ describe("list_agent_files handler", () => {
149
+ it("returns allowlisted workspace and attached hermes profile files for the requested agent", async () => {
150
+ const os = await import("node:os");
151
+ const fs = await import("node:fs");
152
+ const nodePath = await import("node:path");
153
+
154
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-runtime-files-"));
155
+ const prevHome = process.env.HOME;
156
+ process.env.HOME = tmp;
157
+ try {
158
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
159
+ fs.mkdirSync(credDir, { recursive: true });
160
+ fs.writeFileSync(
161
+ nodePath.join(credDir, "ag_hermes.json"),
162
+ JSON.stringify({
163
+ version: 1,
164
+ hubUrl: "https://hub.example",
165
+ agentId: "ag_hermes",
166
+ keyId: "k_hermes",
167
+ privateKey: Buffer.alloc(32, 7).toString("base64"),
168
+ runtime: "hermes-agent",
169
+ hermesProfile: "default",
170
+ }),
171
+ );
172
+
173
+ const workspace = nodePath.join(tmp, ".botcord", "agents", "ag_hermes", "workspace");
174
+ fs.mkdirSync(workspace, { recursive: true });
175
+ fs.writeFileSync(nodePath.join(workspace, "memory.md"), "# Memory\nowned\n");
176
+ fs.writeFileSync(nodePath.join(workspace, "task.md"), "# Task\n");
177
+
178
+ const hermesMem = nodePath.join(tmp, ".hermes", "memories");
179
+ fs.mkdirSync(hermesMem, { recursive: true });
180
+ fs.writeFileSync(nodePath.join(tmp, ".hermes", "SOUL.md"), "# Soul\n");
181
+ fs.writeFileSync(nodePath.join(hermesMem, "MEMORY.md"), "# Hermes Memory\n");
182
+
183
+ const handler = createProvisioner({ gateway: makeFakeGateway() as any });
184
+ const res = await handler({
185
+ id: "req_files",
186
+ type: "list_agent_files",
187
+ params: { agentId: "ag_hermes" },
188
+ });
189
+
190
+ expect(res.ok).toBe(true);
191
+ const result = res.result as any;
192
+ expect(result.agentId).toBe("ag_hermes");
193
+ expect(result.runtime).toBe("hermes-agent");
194
+ const byName = Object.fromEntries(result.files.map((f: any) => [f.name, f]));
195
+ expect(byName["workspace/memory.md"].content).toBe("# Memory\nowned\n");
196
+ expect(byName["workspace/task.md"].content).toBe("# Task\n");
197
+ expect(byName["hermes/default/SOUL.md"].content).toBe("# Soul\n");
198
+ expect(byName["hermes/default/memories/MEMORY.md"].content).toBe("# Hermes Memory\n");
199
+ expect(result.files.some((f: any) => f.name.includes("credentials"))).toBe(false);
200
+ } finally {
201
+ if (prevHome === undefined) delete process.env.HOME;
202
+ else process.env.HOME = prevHome;
203
+ }
204
+ });
205
+
206
+ it("filters by daemon-issued file id", async () => {
207
+ const os = await import("node:os");
208
+ const fs = await import("node:fs");
209
+ const nodePath = await import("node:path");
210
+
211
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-runtime-files-"));
212
+ const prevHome = process.env.HOME;
213
+ process.env.HOME = tmp;
214
+ try {
215
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
216
+ fs.mkdirSync(credDir, { recursive: true });
217
+ fs.writeFileSync(
218
+ nodePath.join(credDir, "ag_one.json"),
219
+ JSON.stringify({
220
+ version: 1,
221
+ hubUrl: "https://hub.example",
222
+ agentId: "ag_one",
223
+ keyId: "k_one",
224
+ privateKey: Buffer.alloc(32, 8).toString("base64"),
225
+ runtime: "claude-code",
226
+ }),
227
+ );
228
+ const workspace = nodePath.join(tmp, ".botcord", "agents", "ag_one", "workspace");
229
+ fs.mkdirSync(workspace, { recursive: true });
230
+ fs.writeFileSync(nodePath.join(workspace, "memory.md"), "# Memory\n");
231
+ fs.writeFileSync(nodePath.join(workspace, "task.md"), "# Task\n");
232
+
233
+ const handler = createProvisioner({ gateway: makeFakeGateway() as any });
234
+ const res = await handler({
235
+ id: "req_one_file",
236
+ type: "list_agent_files",
237
+ params: { agentId: "ag_one", fileId: "workspace:task.md" },
238
+ });
239
+
240
+ expect(res.ok).toBe(true);
241
+ const result = res.result as any;
242
+ expect(result.files).toHaveLength(1);
243
+ expect(result.files[0].name).toBe("workspace/task.md");
244
+ expect(result.files[0].content).toBe("# Task\n");
245
+ } finally {
246
+ if (prevHome === undefined) delete process.env.HOME;
247
+ else process.env.HOME = prevHome;
248
+ }
249
+ });
250
+ });
251
+
148
252
  describe("reload_config handler", () => {
149
253
  it("adds agents listed in config but missing from gateway", async () => {
150
254
  mockState.cfg = {
@@ -1585,3 +1689,78 @@ describe("provision_agent hermes profile attach", () => {
1585
1689
  });
1586
1690
  });
1587
1691
  });
1692
+
1693
+ describe("W8: gateway frame param validation in provision dispatch", () => {
1694
+ it("rejects malformed UPSERT_GATEWAY (missing accountId) with bad_params", async () => {
1695
+ const gw = makeFakeGateway(["ag_a"]);
1696
+ const provisioner = createProvisioner({
1697
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1698
+ });
1699
+ const ack = await provisioner({
1700
+ id: "req_w8a",
1701
+ type: CONTROL_FRAME_TYPES.UPSERT_GATEWAY,
1702
+ params: { id: "gw_x", type: "telegram" } as unknown as Record<string, unknown>,
1703
+ });
1704
+ expect(ack.ok).toBe(false);
1705
+ expect(ack.error?.code).toBe("bad_params");
1706
+ expect(ack.error?.message).toContain("accountId");
1707
+ expect(gw.addChannel).not.toHaveBeenCalled();
1708
+ });
1709
+
1710
+ it("rejects malformed REMOVE_GATEWAY (params is not an object)", async () => {
1711
+ const gw = makeFakeGateway();
1712
+ const provisioner = createProvisioner({
1713
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1714
+ });
1715
+ const ack = await provisioner({
1716
+ id: "req_w8b",
1717
+ type: CONTROL_FRAME_TYPES.REMOVE_GATEWAY,
1718
+ // @ts-expect-error — exercising the runtime guard
1719
+ params: "not-an-object",
1720
+ });
1721
+ expect(ack.ok).toBe(false);
1722
+ expect(ack.error?.code).toBe("bad_params");
1723
+ });
1724
+
1725
+ it("rejects malformed TEST_GATEWAY (id missing)", async () => {
1726
+ const gw = makeFakeGateway();
1727
+ const provisioner = createProvisioner({
1728
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1729
+ });
1730
+ const ack = await provisioner({
1731
+ id: "req_w8c",
1732
+ type: CONTROL_FRAME_TYPES.TEST_GATEWAY,
1733
+ params: {},
1734
+ });
1735
+ expect(ack.ok).toBe(false);
1736
+ expect(ack.error?.code).toBe("bad_params");
1737
+ });
1738
+
1739
+ it("rejects malformed GATEWAY_LOGIN_START (missing provider)", async () => {
1740
+ const gw = makeFakeGateway();
1741
+ const provisioner = createProvisioner({
1742
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1743
+ });
1744
+ const ack = await provisioner({
1745
+ id: "req_w8d",
1746
+ type: CONTROL_FRAME_TYPES.GATEWAY_LOGIN_START,
1747
+ params: { accountId: "ag_a" },
1748
+ });
1749
+ expect(ack.ok).toBe(false);
1750
+ expect(ack.error?.code).toBe("bad_params");
1751
+ });
1752
+
1753
+ it("rejects malformed GATEWAY_LOGIN_STATUS (missing loginId)", async () => {
1754
+ const gw = makeFakeGateway();
1755
+ const provisioner = createProvisioner({
1756
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1757
+ });
1758
+ const ack = await provisioner({
1759
+ id: "req_w8e",
1760
+ type: CONTROL_FRAME_TYPES.GATEWAY_LOGIN_STATUS,
1761
+ params: { provider: "wechat" },
1762
+ });
1763
+ expect(ack.ok).toBe(false);
1764
+ expect(ack.error?.code).toBe("bad_params");
1765
+ });
1766
+ });
@@ -0,0 +1,70 @@
1
+ import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ defaultGatewaySecretPath,
7
+ loadGatewaySecret,
8
+ saveGatewaySecret,
9
+ deleteGatewaySecret,
10
+ } from "../gateway/channels/secret-store.js";
11
+
12
+ describe("secret-store", () => {
13
+ let tmp: string;
14
+
15
+ beforeEach(() => {
16
+ tmp = mkdtempSync(path.join(tmpdir(), "botcord-secret-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(tmp, { recursive: true, force: true });
21
+ });
22
+
23
+ it("derives the default path under ~/.botcord/daemon/gateways when no override is given", () => {
24
+ const file = defaultGatewaySecretPath("gw_abc");
25
+ expect(file.endsWith(path.join(".botcord", "daemon", "gateways", "gw_abc.json"))).toBe(true);
26
+ });
27
+
28
+ it("honors an explicit override path", () => {
29
+ const override = path.join(tmp, "custom.json");
30
+ expect(defaultGatewaySecretPath("gw_abc", override)).toBe(override);
31
+ });
32
+
33
+ it("writes the secret with mode 0600 in a 0700 directory and round-trips the payload", () => {
34
+ const override = path.join(tmp, "gw1", "secret.json");
35
+ const written = saveGatewaySecret("gw1", { botToken: "tok-123" }, override);
36
+ expect(written).toBe(override);
37
+ expect(existsSync(override)).toBe(true);
38
+ const fileMode = statSync(override).mode & 0o777;
39
+ expect(fileMode).toBe(0o600);
40
+ const dirMode = statSync(path.dirname(override)).mode & 0o777;
41
+ expect(dirMode).toBe(0o700);
42
+
43
+ const loaded = loadGatewaySecret<{ botToken: string }>("gw1", override);
44
+ expect(loaded).toEqual({ botToken: "tok-123" });
45
+ });
46
+
47
+ it("returns null when the secret file does not exist", () => {
48
+ const override = path.join(tmp, "missing.json");
49
+ expect(loadGatewaySecret("gw1", override)).toBeNull();
50
+ });
51
+
52
+ it("deleteGatewaySecret removes the file and is idempotent", () => {
53
+ const override = path.join(tmp, "gw1.json");
54
+ saveGatewaySecret("gw1", { botToken: "x" }, override);
55
+ expect(existsSync(override)).toBe(true);
56
+ deleteGatewaySecret("gw1", override);
57
+ expect(existsSync(override)).toBe(false);
58
+ // Second call must not throw.
59
+ deleteGatewaySecret("gw1", override);
60
+ });
61
+
62
+ it("W3: returns null and does not throw when the secret file contains invalid JSON", () => {
63
+ const override = path.join(tmp, "corrupt.json");
64
+ // Write intentionally corrupt JSON.
65
+ writeFileSync(override, "{ broken json }}}");
66
+ // Must not throw; must return null.
67
+ expect(() => loadGatewaySecret("gw_corrupt", override)).not.toThrow();
68
+ expect(loadGatewaySecret("gw_corrupt", override)).toBeNull();
69
+ });
70
+ });
@@ -0,0 +1,119 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ GatewayStateStore,
7
+ defaultGatewayStatePath,
8
+ } from "../gateway/channels/state-store.js";
9
+
10
+ describe("state-store", () => {
11
+ let tmp: string;
12
+
13
+ beforeEach(() => {
14
+ tmp = mkdtempSync(path.join(tmpdir(), "botcord-state-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tmp, { recursive: true, force: true });
19
+ });
20
+
21
+ it("derives the default state path under ~/.botcord/daemon/gateways", () => {
22
+ const file = defaultGatewayStatePath("gw_abc");
23
+ expect(
24
+ file.endsWith(path.join(".botcord", "daemon", "gateways", "gw_abc.state.json")),
25
+ ).toBe(true);
26
+ });
27
+
28
+ it("round-trips cursor + provider state across instances when flushed", () => {
29
+ const file = path.join(tmp, "gw.state.json");
30
+ const store = new GatewayStateStore("gw", { override: file, debounceMs: 0 });
31
+ store.update({ cursor: "cur-1", providerState: { typingTicket: "tk1" } });
32
+ expect(store.getCursor()).toBe("cur-1");
33
+ expect(existsSync(file)).toBe(true);
34
+
35
+ const reopened = new GatewayStateStore("gw", { override: file, debounceMs: 0 });
36
+ expect(reopened.getCursor()).toBe("cur-1");
37
+ expect(reopened.getProviderState()).toEqual({ typingTicket: "tk1" });
38
+ });
39
+
40
+ it("debounces multiple writes into a single flush", async () => {
41
+ const file = path.join(tmp, "gw.state.json");
42
+ const store = new GatewayStateStore("gw", { override: file, debounceMs: 30 });
43
+ store.update({ cursor: "a" });
44
+ store.update({ cursor: "b" });
45
+ store.update({ cursor: "c" });
46
+ // No file written yet — debounce window still open.
47
+ expect(existsSync(file)).toBe(false);
48
+
49
+ await new Promise((r) => setTimeout(r, 60));
50
+ expect(existsSync(file)).toBe(true);
51
+ const onDisk = JSON.parse(readFileSync(file, "utf8")) as { cursor?: string };
52
+ expect(onDisk.cursor).toBe("c");
53
+ store.close();
54
+ });
55
+
56
+ it("W9: write failure leaves state dirty and surfaces lastError", () => {
57
+ // Point the file at a path whose parent path is a regular file — mkdirSync
58
+ // recursive cannot turn that into a directory, so writeStateSync throws.
59
+ const blockerFile = path.join(tmp, "blocker");
60
+ require("node:fs").writeFileSync(blockerFile, "x");
61
+ const file = path.join(blockerFile, "child.state.json");
62
+ const store = new GatewayStateStore("gw", { override: file, debounceMs: 0 });
63
+ expect(() => store.update({ cursor: "v1" })).toThrow();
64
+ expect(store.lastError).not.toBeNull();
65
+ // Repair: remove the blocker so the next write succeeds, and assert the
66
+ // pending state is still in memory and is written by the next update().
67
+ require("node:fs").rmSync(blockerFile, { force: true });
68
+ store.update({ cursor: "v2" });
69
+ expect(existsSync(file)).toBe(true);
70
+ expect(JSON.parse(readFileSync(file, "utf8")).cursor).toBe("v2");
71
+ expect(store.lastError).toBeNull();
72
+ store.close();
73
+ });
74
+
75
+ it("W3: scheduleFlushRetry stops after MAX_FLUSH_RETRIES and sets lastError", async () => {
76
+ // Use an unwritable path by making parent a regular file — persistent failure.
77
+ const blockerFile = path.join(tmp, "blocker2");
78
+ writeFileSync(blockerFile, "x");
79
+ const file = path.join(blockerFile, "child.state.json");
80
+
81
+ vi.useFakeTimers();
82
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
83
+
84
+ const store = new GatewayStateStore("gw-retry", {
85
+ override: file,
86
+ // debounceMs > 0 so scheduleFlushRetry arms timers (not sync mode).
87
+ // The retry clamps to 250ms but with fake timers we advance manually.
88
+ debounceMs: 10,
89
+ });
90
+
91
+ store.update({ cursor: "fail" });
92
+
93
+ // Advance through 11 timer ticks (debounce + 10 retries); each retry is
94
+ // 250ms (the clamped minimum). The 11th tick should trigger "giving up".
95
+ for (let i = 0; i < 12; i++) {
96
+ await vi.advanceTimersByTimeAsync(300);
97
+ }
98
+
99
+ // lastError must be set (persistent failure)
100
+ expect(store.lastError).not.toBeNull();
101
+ // "giving up" log emitted exactly once
102
+ expect(errSpy).toHaveBeenCalledTimes(1);
103
+ expect(errSpy.mock.calls[0][0]).toMatch(/giving up/);
104
+
105
+ vi.useRealTimers();
106
+ errSpy.mockRestore();
107
+ });
108
+
109
+ it("flush() forces an immediate synchronous write", () => {
110
+ const file = path.join(tmp, "gw.state.json");
111
+ const store = new GatewayStateStore("gw", { override: file, debounceMs: 5_000 });
112
+ store.update({ cursor: "x" });
113
+ expect(existsSync(file)).toBe(false);
114
+ store.flush();
115
+ expect(existsSync(file)).toBe(true);
116
+ expect(JSON.parse(readFileSync(file, "utf8")).cursor).toBe("x");
117
+ store.close();
118
+ });
119
+ });
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DaemonConfig } from "../config.js";
3
+ import {
4
+ BOTCORD_CHANNEL_TYPE,
5
+ TELEGRAM_CHANNEL_TYPE,
6
+ WECHAT_CHANNEL_TYPE,
7
+ toGatewayConfig,
8
+ } from "../daemon-config-map.js";
9
+ import { createDaemonChannel } from "../daemon.js";
10
+ import type { GatewayChannelConfig } from "../gateway/index.js";
11
+
12
+ function baseConfig(partial: Partial<DaemonConfig> = {}): DaemonConfig {
13
+ return {
14
+ agentId: "ag_daemon",
15
+ defaultRoute: { adapter: "claude-code", cwd: "/home/alice" },
16
+ routes: [],
17
+ streamBlocks: true,
18
+ ...partial,
19
+ };
20
+ }
21
+
22
+ describe("toGatewayConfig + thirdPartyGateways", () => {
23
+ it("appends one channel per enabled third-party gateway after the BotCord channels", () => {
24
+ const cfg = baseConfig({
25
+ thirdPartyGateways: [
26
+ {
27
+ id: "gw_tg_1",
28
+ type: "telegram",
29
+ accountId: "ag_daemon",
30
+ allowedChatIds: ["123"],
31
+ },
32
+ {
33
+ id: "gw_wx_1",
34
+ type: "wechat",
35
+ accountId: "ag_daemon",
36
+ baseUrl: "https://ilinkai.weixin.qq.com",
37
+ allowedSenderIds: ["abc@im.wechat"],
38
+ splitAt: 1800,
39
+ },
40
+ ],
41
+ });
42
+ const gw = toGatewayConfig(cfg);
43
+ expect(gw.channels.map((c) => ({ id: c.id, type: c.type }))).toEqual([
44
+ { id: "ag_daemon", type: BOTCORD_CHANNEL_TYPE },
45
+ { id: "gw_tg_1", type: TELEGRAM_CHANNEL_TYPE },
46
+ { id: "gw_wx_1", type: WECHAT_CHANNEL_TYPE },
47
+ ]);
48
+ const tg = gw.channels[1]!;
49
+ expect(tg.accountId).toBe("ag_daemon");
50
+ expect(tg.allowedChatIds).toEqual(["123"]);
51
+ const wx = gw.channels[2]!;
52
+ expect(wx.baseUrl).toBe("https://ilinkai.weixin.qq.com");
53
+ expect(wx.allowedSenderIds).toEqual(["abc@im.wechat"]);
54
+ expect(wx.splitAt).toBe(1800);
55
+ });
56
+
57
+ it("filters out gateways with enabled === false", () => {
58
+ const cfg = baseConfig({
59
+ thirdPartyGateways: [
60
+ { id: "gw_off", type: "telegram", accountId: "ag_daemon", enabled: false },
61
+ { id: "gw_on", type: "wechat", accountId: "ag_daemon", enabled: true },
62
+ ],
63
+ });
64
+ const gw = toGatewayConfig(cfg);
65
+ const ids = gw.channels.map((c) => c.id);
66
+ expect(ids).toContain("gw_on");
67
+ expect(ids).not.toContain("gw_off");
68
+ });
69
+
70
+ it("treats omitted enabled as enabled", () => {
71
+ const cfg = baseConfig({
72
+ thirdPartyGateways: [{ id: "gw_a", type: "telegram", accountId: "ag_daemon" }],
73
+ });
74
+ const gw = toGatewayConfig(cfg);
75
+ expect(gw.channels.some((c) => c.id === "gw_a")).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe("createDaemonChannel", () => {
80
+ const deps = {
81
+ credentialPathByAgentId: new Map<string, string>(),
82
+ };
83
+
84
+ it("dispatches botcord type to the BotCord adapter", () => {
85
+ const chCfg: GatewayChannelConfig = {
86
+ id: "ag_x",
87
+ type: "botcord",
88
+ accountId: "ag_x",
89
+ agentId: "ag_x",
90
+ };
91
+ const adapter = createDaemonChannel(chCfg, deps);
92
+ expect(adapter.type).toBe("botcord");
93
+ expect(adapter.id).toBe("ag_x");
94
+ });
95
+
96
+ it("dispatches telegram type to the Telegram adapter", () => {
97
+ const chCfg: GatewayChannelConfig = {
98
+ id: "gw_tg_1",
99
+ type: "telegram",
100
+ accountId: "ag_x",
101
+ };
102
+ const adapter = createDaemonChannel(chCfg, deps);
103
+ expect(adapter.type).toBe("telegram");
104
+ expect(adapter.id).toBe("gw_tg_1");
105
+ });
106
+
107
+ it("dispatches wechat type to the WeChat adapter", () => {
108
+ const chCfg: GatewayChannelConfig = {
109
+ id: "gw_wx_1",
110
+ type: "wechat",
111
+ accountId: "ag_x",
112
+ };
113
+ const adapter = createDaemonChannel(chCfg, deps);
114
+ expect(adapter.type).toBe("wechat");
115
+ expect(adapter.id).toBe("gw_wx_1");
116
+ });
117
+
118
+ it("throws on unknown channel type", () => {
119
+ const chCfg: GatewayChannelConfig = {
120
+ id: "gw_x",
121
+ type: "unknown-provider",
122
+ accountId: "ag_x",
123
+ };
124
+ expect(() => createDaemonChannel(chCfg, deps)).toThrow(/unknown channel type "unknown-provider"/);
125
+ });
126
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ assertSafeBaseUrl,
4
+ UnsafeBaseUrlError,
5
+ } from "../gateway/channels/url-guard.js";
6
+ import { mintLoginId } from "../gateway/channels/login-session.js";
7
+
8
+ describe("assertSafeBaseUrl (W9 allowlist)", () => {
9
+ it("accepts undefined / empty (caller falls back to default)", () => {
10
+ expect(() => assertSafeBaseUrl(undefined)).not.toThrow();
11
+ expect(() => assertSafeBaseUrl(null)).not.toThrow();
12
+ expect(() => assertSafeBaseUrl("")).not.toThrow();
13
+ });
14
+
15
+ it("accepts the production iLink and Telegram base URLs", () => {
16
+ expect(() => assertSafeBaseUrl("https://ilinkai.weixin.qq.com")).not.toThrow();
17
+ expect(() => assertSafeBaseUrl("https://api.telegram.org")).not.toThrow();
18
+ });
19
+
20
+ it("rejects http://", () => {
21
+ expect(() => assertSafeBaseUrl("http://api.telegram.org")).toThrow(UnsafeBaseUrlError);
22
+ });
23
+
24
+ it("rejects loopback hostname (allowlist miss)", () => {
25
+ expect(() => assertSafeBaseUrl("https://localhost")).toThrow(UnsafeBaseUrlError);
26
+ expect(() => assertSafeBaseUrl("https://localhost:9999")).toThrow(UnsafeBaseUrlError);
27
+ });
28
+
29
+ it("rejects loopback IPv4", () => {
30
+ expect(() => assertSafeBaseUrl("https://127.0.0.1")).toThrow(UnsafeBaseUrlError);
31
+ expect(() => assertSafeBaseUrl("https://127.1.2.3")).toThrow(UnsafeBaseUrlError);
32
+ });
33
+
34
+ it("rejects link-local AWS metadata IP", () => {
35
+ expect(() => assertSafeBaseUrl("https://169.254.169.254")).toThrow(UnsafeBaseUrlError);
36
+ });
37
+
38
+ it("rejects RFC1918 private IPv4", () => {
39
+ expect(() => assertSafeBaseUrl("https://10.0.0.5")).toThrow(UnsafeBaseUrlError);
40
+ expect(() => assertSafeBaseUrl("https://192.168.1.1")).toThrow(UnsafeBaseUrlError);
41
+ expect(() => assertSafeBaseUrl("https://172.16.5.5")).toThrow(UnsafeBaseUrlError);
42
+ });
43
+
44
+ it("rejects loopback IPv6", () => {
45
+ expect(() => assertSafeBaseUrl("https://[::1]")).toThrow(UnsafeBaseUrlError);
46
+ });
47
+
48
+ it("rejects malformed URLs", () => {
49
+ expect(() => assertSafeBaseUrl("not-a-url")).toThrow(UnsafeBaseUrlError);
50
+ });
51
+
52
+ it("W9: rejects GCP metadata hostnames not in allowlist", () => {
53
+ expect(() => assertSafeBaseUrl("https://metadata.google.internal")).toThrow(UnsafeBaseUrlError);
54
+ expect(() => assertSafeBaseUrl("https://metadata")).toThrow(UnsafeBaseUrlError);
55
+ });
56
+
57
+ it("W9: rejects *.internal and *.svc.cluster.local hostnames", () => {
58
+ expect(() => assertSafeBaseUrl("https://evil.internal")).toThrow(UnsafeBaseUrlError);
59
+ expect(() => assertSafeBaseUrl("https://my-service.svc.cluster.local")).toThrow(UnsafeBaseUrlError);
60
+ });
61
+
62
+ it("W9: rejects any arbitrary hostname not in allowlist", () => {
63
+ expect(() => assertSafeBaseUrl("https://attacker.com")).toThrow(UnsafeBaseUrlError);
64
+ expect(() => assertSafeBaseUrl("https://evil.api.telegram.org")).toThrow(UnsafeBaseUrlError);
65
+ });
66
+ });
67
+
68
+ describe("W5: mintLoginId uses 128-bit entropy", () => {
69
+ it("generates a wechat loginId with 32 hex chars in the random segment", () => {
70
+ const id = mintLoginId("wechat");
71
+ expect(id).toMatch(/^wxl_/);
72
+ const rand = id.split("_")[2]!;
73
+ // randomBytes(16).toString("hex") = 32 hex chars
74
+ expect(rand).toHaveLength(32);
75
+ expect(rand).toMatch(/^[0-9a-f]{32}$/);
76
+ });
77
+
78
+ it("generates a telegram loginId with 32 hex chars in the random segment", () => {
79
+ const id = mintLoginId("telegram");
80
+ expect(id).toMatch(/^tgl_/);
81
+ const rand = id.split("_")[2]!;
82
+ expect(rand).toHaveLength(32);
83
+ expect(rand).toMatch(/^[0-9a-f]{32}$/);
84
+ });
85
+ });