@botcord/daemon 0.2.5 → 0.2.8
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/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
discoverLocalOpenclawGateways,
|
|
7
|
+
mergeOpenclawGateways,
|
|
8
|
+
} from "../openclaw-discovery.js";
|
|
9
|
+
import type { DaemonConfig } from "../config.js";
|
|
10
|
+
import type { WsEndpointProbeFn } from "../provision.js";
|
|
11
|
+
|
|
12
|
+
let tmp: string | null = null;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
16
|
+
tmp = null;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function tempDir(): string {
|
|
20
|
+
tmp = mkdtempSync(path.join(tmpdir(), "openclaw-discovery-"));
|
|
21
|
+
return tmp;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function baseConfig(): DaemonConfig {
|
|
25
|
+
return {
|
|
26
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
27
|
+
routes: [],
|
|
28
|
+
streamBlocks: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("discoverLocalOpenclawGateways", () => {
|
|
33
|
+
it("discovers JSON and TOML acp config files", async () => {
|
|
34
|
+
const dir = tempDir();
|
|
35
|
+
writeFileSync(
|
|
36
|
+
path.join(dir, "one.json"),
|
|
37
|
+
JSON.stringify({ acp: { url: "ws://127.0.0.1:18789/acp", tokenFile: "/tmp/token" } }),
|
|
38
|
+
);
|
|
39
|
+
writeFileSync(
|
|
40
|
+
path.join(dir, "two.toml"),
|
|
41
|
+
['[acp]', 'url = "ws://127.0.0.1:18790/acp"', 'token = "secret"'].join("\n"),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const found = await discoverLocalOpenclawGateways({
|
|
45
|
+
searchPaths: [dir],
|
|
46
|
+
defaultPorts: [],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(found).toEqual(
|
|
50
|
+
expect.arrayContaining([
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
url: "ws://127.0.0.1:18789/acp",
|
|
53
|
+
tokenFile: "/tmp/token",
|
|
54
|
+
source: "config-file",
|
|
55
|
+
}),
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
url: "ws://127.0.0.1:18790/acp",
|
|
58
|
+
token: "secret",
|
|
59
|
+
source: "config-file",
|
|
60
|
+
}),
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses OPENCLAW_ACP_URL and token env vars", async () => {
|
|
66
|
+
const found = await discoverLocalOpenclawGateways({
|
|
67
|
+
searchPaths: [],
|
|
68
|
+
defaultPorts: [],
|
|
69
|
+
env: {
|
|
70
|
+
OPENCLAW_ACP_URL: "ws://127.0.0.1:18888/acp",
|
|
71
|
+
OPENCLAW_ACP_TOKEN: "env-token",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(found).toEqual([
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
url: "ws://127.0.0.1:18888/acp",
|
|
78
|
+
token: "env-token",
|
|
79
|
+
source: "env",
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("adds default-port candidates only when the probe succeeds", async () => {
|
|
85
|
+
const probe = vi.fn<WsEndpointProbeFn>(async ({ url }) => ({
|
|
86
|
+
ok: url.includes("18789"),
|
|
87
|
+
agents: [],
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const found = await discoverLocalOpenclawGateways({
|
|
91
|
+
searchPaths: [],
|
|
92
|
+
defaultPorts: [18789, 18790],
|
|
93
|
+
probe,
|
|
94
|
+
timeoutMs: 10,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(probe).toHaveBeenCalledTimes(2);
|
|
98
|
+
expect(found.map((g) => g.url)).toEqual(["ws://127.0.0.1:18789"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("prefers config-file auth details over lower-priority duplicate sources", async () => {
|
|
102
|
+
const dir = tempDir();
|
|
103
|
+
writeFileSync(
|
|
104
|
+
path.join(dir, "one.json"),
|
|
105
|
+
JSON.stringify({ acp: { url: "ws://127.0.0.1:18789", token: "file-token" } }),
|
|
106
|
+
);
|
|
107
|
+
const probe = vi.fn<WsEndpointProbeFn>(async () => ({ ok: true }));
|
|
108
|
+
|
|
109
|
+
const found = await discoverLocalOpenclawGateways({
|
|
110
|
+
searchPaths: [dir],
|
|
111
|
+
defaultPorts: [18789],
|
|
112
|
+
probe,
|
|
113
|
+
env: {
|
|
114
|
+
OPENCLAW_ACP_URL: "ws://127.0.0.1:18789",
|
|
115
|
+
OPENCLAW_ACP_TOKEN: "env-token",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(found).toHaveLength(1);
|
|
120
|
+
expect(found[0]).toEqual(
|
|
121
|
+
expect.objectContaining({ source: "config-file", token: "file-token" }),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("mergeOpenclawGateways", () => {
|
|
127
|
+
it("appends new URLs and keeps existing profiles untouched", () => {
|
|
128
|
+
const cfg = baseConfig();
|
|
129
|
+
cfg.openclawGateways = [{ name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" }];
|
|
130
|
+
const merged = mergeOpenclawGateways(cfg, [
|
|
131
|
+
{
|
|
132
|
+
name: "openclaw-127-0-0-1-18789",
|
|
133
|
+
url: "ws://127.0.0.1:18789/acp",
|
|
134
|
+
token: "discovered-token",
|
|
135
|
+
source: "env",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "openclaw-127-0-0-1-18790",
|
|
139
|
+
url: "ws://127.0.0.1:18790/acp",
|
|
140
|
+
source: "default-port",
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(merged.changed).toBe(true);
|
|
145
|
+
expect(merged.cfg.openclawGateways).toEqual([
|
|
146
|
+
{ name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" },
|
|
147
|
+
{ name: "openclaw-127-0-0-1-18790", url: "ws://127.0.0.1:18790/acp" },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PolicyResolver } from "../gateway/policy-resolver.js";
|
|
3
|
+
import type { AttentionPolicy } from "@botcord/protocol-core";
|
|
4
|
+
|
|
5
|
+
describe("PolicyResolver", () => {
|
|
6
|
+
it("returns default policy when fetchGlobal returns undefined", async () => {
|
|
7
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
8
|
+
const p = await resolver.resolve("ag_a", null);
|
|
9
|
+
expect(p.mode).toBe("always");
|
|
10
|
+
expect(p.keywords).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("caches the global fetch result and reuses on the next resolve", async () => {
|
|
14
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "muted", keywords: [] }) as AttentionPolicy);
|
|
15
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
16
|
+
await resolver.resolve("ag_a", null);
|
|
17
|
+
await resolver.resolve("ag_a", null);
|
|
18
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("invalidate(agent) drops all entries for that agent", async () => {
|
|
22
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "always", keywords: [] }) as AttentionPolicy);
|
|
23
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
24
|
+
await resolver.resolve("ag_a", null);
|
|
25
|
+
await resolver.resolve("ag_a", null);
|
|
26
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
27
|
+
resolver.invalidate("ag_a");
|
|
28
|
+
await resolver.resolve("ag_a", null);
|
|
29
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("invalidate(agent, room) only drops the matching room entry", async () => {
|
|
33
|
+
const policy: AttentionPolicy = { mode: "always", keywords: [] };
|
|
34
|
+
const fetchGlobal = vi.fn(async () => policy);
|
|
35
|
+
const fetchEffective = vi.fn(async () => policy);
|
|
36
|
+
const resolver = new PolicyResolver({ fetchGlobal, fetchEffective });
|
|
37
|
+
|
|
38
|
+
await resolver.resolve("ag_a", null);
|
|
39
|
+
await resolver.resolve("ag_a", "rm_1");
|
|
40
|
+
await resolver.resolve("ag_a", "rm_2");
|
|
41
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(fetchEffective).toHaveBeenCalledTimes(2);
|
|
43
|
+
|
|
44
|
+
resolver.invalidate("ag_a", "rm_1");
|
|
45
|
+
await resolver.resolve("ag_a", null); // still cached
|
|
46
|
+
await resolver.resolve("ag_a", "rm_2"); // still cached
|
|
47
|
+
await resolver.resolve("ag_a", "rm_1"); // refetched
|
|
48
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(fetchEffective).toHaveBeenCalledTimes(3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("put() installs a policy without going through fetch", async () => {
|
|
53
|
+
const fetchGlobal = vi.fn(async () => undefined);
|
|
54
|
+
const resolver = new PolicyResolver({ fetchGlobal });
|
|
55
|
+
resolver.put("ag_a", null, { mode: "muted", keywords: [] });
|
|
56
|
+
const p = await resolver.resolve("ag_a", null);
|
|
57
|
+
expect(p.mode).toBe("muted");
|
|
58
|
+
expect(fetchGlobal).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forces DM rooms (rm_dm_*) to mode=always even if cached muted", async () => {
|
|
62
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
63
|
+
resolver.put("ag_a", "rm_dm_xyz", { mode: "muted", keywords: [] });
|
|
64
|
+
const p = await resolver.resolve("ag_a", "rm_dm_xyz");
|
|
65
|
+
expect(p.mode).toBe("always");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to defaults when fetch throws", async () => {
|
|
69
|
+
const resolver = new PolicyResolver({
|
|
70
|
+
fetchGlobal: async () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const p = await resolver.resolve("ag_a", null);
|
|
75
|
+
expect(p.mode).toBe("always");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to the cached global when resolving a room with no override", async () => {
|
|
79
|
+
// Regression: prior to the room→global fallback, group messages
|
|
80
|
+
// collapsed to mode=always whenever the daemon had no fetchEffective
|
|
81
|
+
// wired (the default state), silently breaking global mention_only/muted.
|
|
82
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
83
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
84
|
+
const p = await resolver.resolve("ag_a", "rm_1");
|
|
85
|
+
expect(p.mode).toBe("mention_only");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("per-room override wins over the cached global", async () => {
|
|
89
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
90
|
+
resolver.put("ag_a", null, { mode: "always", keywords: [] });
|
|
91
|
+
resolver.put("ag_a", "rm_1", { mode: "muted", keywords: [] });
|
|
92
|
+
const p = await resolver.resolve("ag_a", "rm_1");
|
|
93
|
+
expect(p.mode).toBe("muted");
|
|
94
|
+
// Other rooms still inherit the global.
|
|
95
|
+
expect((await resolver.resolve("ag_a", "rm_2")).mode).toBe("always");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("invalidate(agent, room) drops the override and falls back to the cached global", async () => {
|
|
99
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
100
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
101
|
+
resolver.put("ag_a", "rm_1", { mode: "muted", keywords: [] });
|
|
102
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("muted");
|
|
103
|
+
resolver.invalidate("ag_a", "rm_1");
|
|
104
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("mention_only");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("global update via put propagates to inheriting rooms without invalidation", async () => {
|
|
108
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
109
|
+
resolver.put("ag_a", null, { mode: "always", keywords: [] });
|
|
110
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("always");
|
|
111
|
+
// Hub fires policy_updated with new global policy → daemon does put().
|
|
112
|
+
resolver.put("ag_a", null, { mode: "muted", keywords: [] });
|
|
113
|
+
expect((await resolver.resolve("ag_a", "rm_1")).mode).toBe("muted");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("expires cached entries after ttlMs", async () => {
|
|
117
|
+
const fetchGlobal = vi.fn(async () => ({ mode: "muted", keywords: [] }) as AttentionPolicy);
|
|
118
|
+
const resolver = new PolicyResolver({ fetchGlobal, ttlMs: 1 });
|
|
119
|
+
await resolver.resolve("ag_a", null);
|
|
120
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
121
|
+
await resolver.resolve("ag_a", null);
|
|
122
|
+
expect(fetchGlobal).toHaveBeenCalledTimes(2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockState = {
|
|
4
|
+
cfg: {
|
|
5
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
6
|
+
routes: [],
|
|
7
|
+
streamBlocks: true,
|
|
8
|
+
} as Record<string, unknown>,
|
|
9
|
+
saved: [] as Array<Record<string, unknown>>,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
vi.mock("../config.js", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("../config.js")>("../config.js");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
loadConfig: () => JSON.parse(JSON.stringify(mockState.cfg)) as Record<string, unknown>,
|
|
17
|
+
saveConfig: (next: Record<string, unknown>) => {
|
|
18
|
+
mockState.cfg = JSON.parse(JSON.stringify(next)) as Record<string, unknown>;
|
|
19
|
+
mockState.saved.push(JSON.parse(JSON.stringify(next)) as Record<string, unknown>);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { createProvisioner } = await import("../provision.js");
|
|
25
|
+
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
26
|
+
import type { GatewayRoute, GatewayRuntimeSnapshot } from "../gateway/index.js";
|
|
27
|
+
import type { PolicyResolverLike } from "../gateway/policy-resolver.js";
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockState.cfg = {
|
|
31
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
32
|
+
routes: [],
|
|
33
|
+
streamBlocks: true,
|
|
34
|
+
};
|
|
35
|
+
mockState.saved = [];
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function makeFakeGateway(): unknown {
|
|
39
|
+
const snap: GatewayRuntimeSnapshot = { channels: {}, turns: {} };
|
|
40
|
+
return {
|
|
41
|
+
addChannel: vi.fn(async () => {}),
|
|
42
|
+
removeChannel: vi.fn(async () => {}),
|
|
43
|
+
upsertManagedRoute: vi.fn(),
|
|
44
|
+
removeManagedRoute: vi.fn(),
|
|
45
|
+
replaceManagedRoutes: vi.fn(),
|
|
46
|
+
listManagedRoutes: () => [] as GatewayRoute[],
|
|
47
|
+
snapshot: () => snap,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeFakeResolver(): PolicyResolverLike & {
|
|
52
|
+
invalidate: ReturnType<typeof vi.fn>;
|
|
53
|
+
put: ReturnType<typeof vi.fn>;
|
|
54
|
+
resolve: ReturnType<typeof vi.fn>;
|
|
55
|
+
} {
|
|
56
|
+
return {
|
|
57
|
+
resolve: vi.fn(async () => ({ mode: "always", keywords: [] })),
|
|
58
|
+
invalidate: vi.fn(),
|
|
59
|
+
put: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("policy_updated control-frame handler", () => {
|
|
64
|
+
it("invalidates the resolver cache for the agent when payload has no policy", async () => {
|
|
65
|
+
const gw = makeFakeGateway();
|
|
66
|
+
const resolver = makeFakeResolver();
|
|
67
|
+
const provisioner = createProvisioner({
|
|
68
|
+
gateway: gw as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
69
|
+
policyResolver: resolver,
|
|
70
|
+
});
|
|
71
|
+
const ack = await provisioner({
|
|
72
|
+
id: "f1",
|
|
73
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
74
|
+
params: { agent_id: "ag_a" },
|
|
75
|
+
});
|
|
76
|
+
expect(ack.ok).toBe(true);
|
|
77
|
+
expect(resolver.invalidate).toHaveBeenCalledWith("ag_a", undefined);
|
|
78
|
+
expect(resolver.put).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("invalidates only the per-room slot when room_id is present", async () => {
|
|
82
|
+
const resolver = makeFakeResolver();
|
|
83
|
+
const provisioner = createProvisioner({
|
|
84
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
85
|
+
policyResolver: resolver,
|
|
86
|
+
});
|
|
87
|
+
await provisioner({
|
|
88
|
+
id: "f2",
|
|
89
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
90
|
+
params: { agent_id: "ag_a", room_id: "rm_1" },
|
|
91
|
+
});
|
|
92
|
+
expect(resolver.invalidate).toHaveBeenCalledWith("ag_a", "rm_1");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("calls put() with the embedded policy when payload carries one", async () => {
|
|
96
|
+
const resolver = makeFakeResolver();
|
|
97
|
+
const provisioner = createProvisioner({
|
|
98
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
99
|
+
policyResolver: resolver,
|
|
100
|
+
});
|
|
101
|
+
const ack = await provisioner({
|
|
102
|
+
id: "f3",
|
|
103
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
104
|
+
params: {
|
|
105
|
+
agent_id: "ag_a",
|
|
106
|
+
policy: { mode: "keyword", keywords: ["foo", "bar"], muted_until: 123 },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
expect(ack.ok).toBe(true);
|
|
110
|
+
expect(resolver.put).toHaveBeenCalledWith("ag_a", null, {
|
|
111
|
+
mode: "keyword",
|
|
112
|
+
keywords: ["foo", "bar"],
|
|
113
|
+
muted_until: 123,
|
|
114
|
+
});
|
|
115
|
+
expect(resolver.invalidate).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects payloads missing agent_id with bad_params", async () => {
|
|
119
|
+
const resolver = makeFakeResolver();
|
|
120
|
+
const provisioner = createProvisioner({
|
|
121
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
122
|
+
policyResolver: resolver,
|
|
123
|
+
});
|
|
124
|
+
const ack = await provisioner({
|
|
125
|
+
id: "f4",
|
|
126
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
127
|
+
params: {},
|
|
128
|
+
});
|
|
129
|
+
expect(ack.ok).toBe(false);
|
|
130
|
+
expect(ack.error?.code).toBe("bad_params");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("succeeds quietly when no resolver is wired", async () => {
|
|
134
|
+
const provisioner = createProvisioner({
|
|
135
|
+
gateway: makeFakeGateway() as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
136
|
+
});
|
|
137
|
+
const ack = await provisioner({
|
|
138
|
+
id: "f5",
|
|
139
|
+
type: CONTROL_FRAME_TYPES.POLICY_UPDATED,
|
|
140
|
+
params: { agent_id: "ag_a" },
|
|
141
|
+
});
|
|
142
|
+
expect(ack.ok).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|