@botcord/daemon 0.1.1

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 (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ import {
15
+ agentHomeDir,
16
+ agentStateDir,
17
+ agentWorkspaceDir,
18
+ ensureAgentWorkspace,
19
+ } from "../agent-workspace.js";
20
+
21
+ let tmpHome = "";
22
+ let prevHome: string | undefined;
23
+
24
+ beforeEach(() => {
25
+ tmpHome = mkdtempSync(path.join(os.tmpdir(), "daemon-workspace-"));
26
+ prevHome = process.env.HOME;
27
+ process.env.HOME = tmpHome;
28
+ });
29
+
30
+ afterEach(() => {
31
+ if (prevHome === undefined) delete process.env.HOME;
32
+ else process.env.HOME = prevHome;
33
+ if (tmpHome) rmSync(tmpHome, { recursive: true, force: true });
34
+ });
35
+
36
+ describe("ensureAgentWorkspace", () => {
37
+ it("creates the full tree + seed files from a clean slate", () => {
38
+ ensureAgentWorkspace("ag_fresh", {
39
+ displayName: "Writer",
40
+ bio: "A careful assistant.",
41
+ runtime: "claude-code",
42
+ keyId: "k_abc",
43
+ savedAt: "2026-04-23T00:00:00Z",
44
+ });
45
+
46
+ const home = agentHomeDir("ag_fresh");
47
+ const workspace = agentWorkspaceDir("ag_fresh");
48
+ const state = agentStateDir("ag_fresh");
49
+
50
+ expect(home.startsWith(tmpHome)).toBe(true);
51
+ expect(existsSync(home)).toBe(true);
52
+ expect(statSync(home).isDirectory()).toBe(true);
53
+ expect(existsSync(workspace)).toBe(true);
54
+ expect(existsSync(state)).toBe(true);
55
+ expect(existsSync(path.join(workspace, "notes"))).toBe(true);
56
+
57
+ for (const name of ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"]) {
58
+ expect(existsSync(path.join(workspace, name))).toBe(true);
59
+ }
60
+
61
+ const agentsMd = readFileSync(path.join(workspace, "AGENTS.md"), "utf8");
62
+ const claudeMd = readFileSync(path.join(workspace, "CLAUDE.md"), "utf8");
63
+ expect(agentsMd).toBe(claudeMd);
64
+ expect(agentsMd).toContain("# Agent Workspace");
65
+
66
+ const identity = readFileSync(path.join(workspace, "identity.md"), "utf8");
67
+ expect(identity).toContain("ag_fresh");
68
+ expect(identity).toContain("Writer");
69
+ expect(identity).toContain("claude-code");
70
+ expect(identity).toContain("k_abc");
71
+ expect(identity).toContain("2026-04-23T00:00:00Z");
72
+ expect(identity).toContain("A careful assistant.");
73
+ });
74
+
75
+ it("places .gitkeep inside notes/", () => {
76
+ ensureAgentWorkspace("ag_notes", {});
77
+ expect(existsSync(path.join(agentWorkspaceDir("ag_notes"), "notes", ".gitkeep"))).toBe(true);
78
+ });
79
+
80
+ it("does not overwrite a user-modified memory.md on a second call", () => {
81
+ ensureAgentWorkspace("ag_keep", {});
82
+ const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");
83
+ writeFileSync(memoryPath, "my custom notes\n");
84
+
85
+ ensureAgentWorkspace("ag_keep", {});
86
+
87
+ expect(readFileSync(memoryPath, "utf8")).toBe("my custom notes\n");
88
+ });
89
+
90
+ it("identity.md renders the bio placeholder when bio is missing", () => {
91
+ ensureAgentWorkspace("ag_nobio", { displayName: "Nameless" });
92
+ const identity = readFileSync(path.join(agentWorkspaceDir("ag_nobio"), "identity.md"), "utf8");
93
+ expect(identity).toContain("_(none provided at provision time");
94
+ expect(identity).toContain("## Bio");
95
+ });
96
+
97
+ it("identity.md degrades gracefully when runtime/keyId/savedAt are absent", () => {
98
+ ensureAgentWorkspace("ag_sparse", {});
99
+ const identity = readFileSync(path.join(agentWorkspaceDir("ag_sparse"), "identity.md"), "utf8");
100
+ expect(identity).toContain("ag_sparse");
101
+ // Placeholder used for every missing scalar field.
102
+ expect(identity).toContain("_(not set)_");
103
+ expect(identity).toContain("_(none provided at provision time");
104
+ });
105
+
106
+ describe("agentId safety", () => {
107
+ // Defence-in-depth: if a malformed/hostile agentId reached these path
108
+ // builders, `revokeAgent(deleteWorkspace:true)`'s `rmSync(home, {recursive:true})`
109
+ // would happily wipe data outside ~/.botcord/agents/.
110
+ const hostile = [
111
+ "../escape",
112
+ "../../etc",
113
+ "foo/bar",
114
+ "..",
115
+ ".",
116
+ "",
117
+ "has spaces",
118
+ "a\0b",
119
+ "foo/../bar",
120
+ ];
121
+ for (const id of hostile) {
122
+ it(`rejects unsafe agentId ${JSON.stringify(id)}`, () => {
123
+ expect(() => agentHomeDir(id)).toThrow();
124
+ expect(() => agentWorkspaceDir(id)).toThrow();
125
+ expect(() => agentStateDir(id)).toThrow();
126
+ expect(() => ensureAgentWorkspace(id, {})).toThrow();
127
+ });
128
+ }
129
+
130
+ it("accepts realistic agent ids", () => {
131
+ for (const ok of ["ag_abc123", "ag_XYZ_9", "ag-dash-ok", "A1", "ag_0"]) {
132
+ expect(() => agentHomeDir(ok)).not.toThrow();
133
+ }
134
+ });
135
+ });
136
+
137
+ it("tightens perms on a pre-existing agent home with looser mode", () => {
138
+ // Simulate a home dir created by an older daemon with mode 0o755.
139
+ const home = agentHomeDir("ag_upgrade");
140
+ // Creation goes via ensureAgentWorkspace's recursive mkdir, which wouldn't
141
+ // override an existing mode — that's precisely the bug we fix.
142
+ mkdirSync(home, { recursive: true, mode: 0o755 });
143
+ ensureAgentWorkspace("ag_upgrade", {});
144
+ const mode = statSync(home).mode & 0o777;
145
+ expect(mode).toBe(0o700);
146
+ });
147
+ });
@@ -0,0 +1,327 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import {
4
+ buildDaemonWebSocketUrl,
5
+ CONTROL_FRAME_TYPES,
6
+ generateKeypair,
7
+ type ControlFrame,
8
+ } from "@botcord/protocol-core";
9
+ import { sign as nodeSign } from "node:crypto";
10
+ import { ControlChannel, controlSigningInput } from "../control-channel.js";
11
+ import { UserAuthManager, type UserAuthRecord } from "../user-auth.js";
12
+ import * as userAuthModule from "../user-auth.js";
13
+
14
+ function makeAuthRecord(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
15
+ return {
16
+ version: 1,
17
+ userId: "usr_1",
18
+ daemonInstanceId: "dm_1",
19
+ hubUrl: "http://localhost:9000",
20
+ accessToken: "at_1",
21
+ refreshToken: "rt_1",
22
+ expiresAt: Date.now() + 60 * 60_000,
23
+ loggedInAt: new Date().toISOString(),
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Minimal in-memory fake of `ws` that mimics what ControlChannel uses:
30
+ * `open` event after construction, `message`/`close`/`error` listeners,
31
+ * `ping()`, `send()`, `close()`, and `readyState`. Each instance records
32
+ * the URL + headers it was constructed with.
33
+ */
34
+ class FakeWebSocket extends EventEmitter {
35
+ public readyState = 0;
36
+ public sent: string[] = [];
37
+ public closed = false;
38
+ static OPEN = 1;
39
+ constructor(public url: string, public opts: { headers?: Record<string, string> } = {}) {
40
+ super();
41
+ setImmediate(() => {
42
+ this.readyState = FakeWebSocket.OPEN;
43
+ this.emit("open");
44
+ });
45
+ }
46
+ send(data: string): void {
47
+ this.sent.push(data);
48
+ }
49
+ ping(): void {
50
+ /* noop for tests */
51
+ }
52
+ close(): void {
53
+ this.closed = true;
54
+ this.emit("close", 1000, Buffer.from("test"));
55
+ }
56
+ static readonly instances: FakeWebSocket[] = [];
57
+ }
58
+
59
+ function makeFakeCtor(): typeof FakeWebSocket & ((url: string, opts?: unknown) => FakeWebSocket) {
60
+ // The ControlChannel uses `new this.webSocketCtor(url, { headers })`.
61
+ // We capture each instance for assertions.
62
+ function Ctor(url: string, opts: { headers?: Record<string, string> } = {}) {
63
+ const ws = new FakeWebSocket(url, opts);
64
+ FakeWebSocket.instances.push(ws);
65
+ return ws;
66
+ }
67
+ // Inherit static OPEN + readonly enum used by ControlChannel.
68
+ (Ctor as unknown as { OPEN: number }).OPEN = FakeWebSocket.OPEN;
69
+ return Ctor as unknown as typeof FakeWebSocket & ((url: string, opts?: unknown) => FakeWebSocket);
70
+ }
71
+
72
+ describe("buildDaemonWebSocketUrl", () => {
73
+ it("appends a label query string when provided", () => {
74
+ const url = buildDaemonWebSocketUrl("http://localhost:9000", "/daemon/ws", {
75
+ label: "MacBook Pro",
76
+ });
77
+ expect(url).toContain("ws://localhost:9000/daemon/ws?");
78
+ expect(url).toContain("label=MacBook+Pro");
79
+ });
80
+
81
+ it("omits the query string when no label", () => {
82
+ expect(buildDaemonWebSocketUrl("http://localhost:9000", "/daemon/ws")).toBe(
83
+ "ws://localhost:9000/daemon/ws",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("ControlChannel — label propagation", () => {
89
+ beforeEach(() => {
90
+ FakeWebSocket.instances.length = 0;
91
+ });
92
+
93
+ it("sends the label from auth.current.label as ?label= on connect", async () => {
94
+ const auth = new UserAuthManager({
95
+ record: makeAuthRecord({ label: "MacBook Pro" }),
96
+ file: "/tmp/never-written-user-auth.json",
97
+ });
98
+ const ctor = makeFakeCtor();
99
+ const ch = new ControlChannel({
100
+ auth,
101
+ handle: () => ({ ok: true }),
102
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
103
+ hubPublicKey: null,
104
+ });
105
+ await ch.start();
106
+ expect(FakeWebSocket.instances).toHaveLength(1);
107
+ expect(FakeWebSocket.instances[0].url).toContain("label=MacBook+Pro");
108
+ await ch.stop();
109
+ });
110
+
111
+ it("explicit opts.label overrides the persisted label", async () => {
112
+ const auth = new UserAuthManager({
113
+ record: makeAuthRecord({ label: "Old" }),
114
+ file: "/tmp/never-written-user-auth.json",
115
+ });
116
+ const ctor = makeFakeCtor();
117
+ const ch = new ControlChannel({
118
+ auth,
119
+ handle: () => ({ ok: true }),
120
+ label: "New",
121
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
122
+ hubPublicKey: null,
123
+ });
124
+ await ch.start();
125
+ expect(FakeWebSocket.instances[0].url).toContain("label=New");
126
+ await ch.stop();
127
+ });
128
+
129
+ it("omits the query string when there is no label anywhere", async () => {
130
+ const auth = new UserAuthManager({
131
+ record: makeAuthRecord(),
132
+ file: "/tmp/never-written-user-auth.json",
133
+ });
134
+ const ctor = makeFakeCtor();
135
+ const ch = new ControlChannel({
136
+ auth,
137
+ handle: () => ({ ok: true }),
138
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
139
+ hubPublicKey: null,
140
+ });
141
+ await ch.start();
142
+ expect(FakeWebSocket.instances[0].url).not.toContain("?");
143
+ await ch.stop();
144
+ });
145
+ });
146
+
147
+ describe("ControlChannel — Hub signature verification", () => {
148
+ beforeEach(() => {
149
+ FakeWebSocket.instances.length = 0;
150
+ });
151
+
152
+ function ed25519PrivateKeyForSigning(seedB64: string) {
153
+ // Mirror protocol-core/crypto.ts privateKeyFromSeed.
154
+ const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
155
+ const seed = Buffer.from(seedB64, "base64");
156
+ return require("node:crypto").createPrivateKey({
157
+ key: Buffer.concat([prefix, seed]),
158
+ format: "der",
159
+ type: "pkcs8",
160
+ });
161
+ }
162
+
163
+ function signFrame(frame: Omit<ControlFrame, "sig">, privateKeyB64: string): ControlFrame {
164
+ const input = controlSigningInput(frame);
165
+ const pk = ed25519PrivateKeyForSigning(privateKeyB64);
166
+ const sig = nodeSign(null, Buffer.from(input, "utf8"), pk).toString("base64");
167
+ return { ...frame, sig };
168
+ }
169
+
170
+ it("accepts and dispatches a properly-signed frame", async () => {
171
+ const { privateKey, publicKey } = generateKeypair();
172
+ const handler = vi.fn(async () => ({ ok: true, result: { handled: true } }));
173
+ const auth = new UserAuthManager({
174
+ record: makeAuthRecord(),
175
+ file: "/tmp/never-written-user-auth.json",
176
+ });
177
+ const ctor = makeFakeCtor();
178
+ const ch = new ControlChannel({
179
+ auth,
180
+ handle: handler,
181
+ hubPublicKey: publicKey,
182
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
183
+ });
184
+ await ch.start();
185
+ const frame = signFrame(
186
+ { id: "f1", type: CONTROL_FRAME_TYPES.PING, ts: Date.now() },
187
+ privateKey,
188
+ );
189
+ FakeWebSocket.instances[0].emit("message", Buffer.from(JSON.stringify(frame)));
190
+ // Allow the async handler microtask to flush.
191
+ await new Promise((r) => setImmediate(r));
192
+ expect(handler).toHaveBeenCalledOnce();
193
+ await ch.stop();
194
+ });
195
+
196
+ it("rejects frames with no signature when a Hub key is configured", async () => {
197
+ const { publicKey } = generateKeypair();
198
+ const handler = vi.fn(async () => ({ ok: true }));
199
+ const auth = new UserAuthManager({
200
+ record: makeAuthRecord(),
201
+ file: "/tmp/never-written-user-auth.json",
202
+ });
203
+ const ctor = makeFakeCtor();
204
+ const ch = new ControlChannel({
205
+ auth,
206
+ handle: handler,
207
+ hubPublicKey: publicKey,
208
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
209
+ });
210
+ await ch.start();
211
+ const ws = FakeWebSocket.instances[0];
212
+ ws.emit(
213
+ "message",
214
+ Buffer.from(JSON.stringify({ id: "f1", type: "ping", ts: Date.now() })),
215
+ );
216
+ await new Promise((r) => setImmediate(r));
217
+ expect(handler).not.toHaveBeenCalled();
218
+ const acks = ws.sent.map((s) => JSON.parse(s));
219
+ expect(acks[0].ok).toBe(false);
220
+ expect(acks[0].error.code).toBe("unsigned");
221
+ await ch.stop();
222
+ });
223
+
224
+ it("rejects frames whose signature does not verify", async () => {
225
+ const wrongKey = generateKeypair();
226
+ const otherKey = generateKeypair();
227
+ const handler = vi.fn(async () => ({ ok: true }));
228
+ const auth = new UserAuthManager({
229
+ record: makeAuthRecord(),
230
+ file: "/tmp/never-written-user-auth.json",
231
+ });
232
+ const ctor = makeFakeCtor();
233
+ const ch = new ControlChannel({
234
+ auth,
235
+ handle: handler,
236
+ // Daemon trusts otherKey.publicKey; Hub signs with wrongKey.privateKey.
237
+ hubPublicKey: otherKey.publicKey,
238
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
239
+ });
240
+ await ch.start();
241
+ const frame = signFrame(
242
+ { id: "f2", type: CONTROL_FRAME_TYPES.PING, ts: Date.now() },
243
+ wrongKey.privateKey,
244
+ );
245
+ const ws = FakeWebSocket.instances[0];
246
+ ws.emit("message", Buffer.from(JSON.stringify(frame)));
247
+ await new Promise((r) => setImmediate(r));
248
+ expect(handler).not.toHaveBeenCalled();
249
+ const acks = ws.sent.map((s) => JSON.parse(s));
250
+ expect(acks[0].ok).toBe(false);
251
+ expect(acks[0].error.code).toBe("bad_signature");
252
+ await ch.stop();
253
+ });
254
+
255
+ it("dispatches unsigned frames when no Hub key is configured (P1 dev mode)", async () => {
256
+ const handler = vi.fn(async () => ({ ok: true }));
257
+ const auth = new UserAuthManager({
258
+ record: makeAuthRecord(),
259
+ file: "/tmp/never-written-user-auth.json",
260
+ });
261
+ const ctor = makeFakeCtor();
262
+ const ch = new ControlChannel({
263
+ auth,
264
+ handle: handler,
265
+ hubPublicKey: null,
266
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
267
+ });
268
+ await ch.start();
269
+ FakeWebSocket.instances[0].emit(
270
+ "message",
271
+ Buffer.from(JSON.stringify({ id: "f1", type: "ping", ts: Date.now() })),
272
+ );
273
+ await new Promise((r) => setImmediate(r));
274
+ expect(handler).toHaveBeenCalledOnce();
275
+ await ch.stop();
276
+ });
277
+ });
278
+
279
+ describe("ControlChannel — REVOKE frame (plan §6.3)", () => {
280
+ beforeEach(() => {
281
+ FakeWebSocket.instances.length = 0;
282
+ });
283
+
284
+ it("acks revoke, writes auth-expired flag, and stops reconnecting without invoking the user handler", async () => {
285
+ const writeSpy = vi.spyOn(userAuthModule, "writeAuthExpiredFlag").mockImplementation(() => {});
286
+
287
+ const handler = vi.fn(async () => ({ ok: true }));
288
+ const auth = new UserAuthManager({
289
+ record: makeAuthRecord(),
290
+ file: "/tmp/never-written-user-auth.json",
291
+ });
292
+ const ctor = makeFakeCtor();
293
+ const ch = new ControlChannel({
294
+ auth,
295
+ handle: handler,
296
+ hubPublicKey: null,
297
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
298
+ });
299
+ await ch.start();
300
+ const ws = FakeWebSocket.instances[0];
301
+ ws.emit(
302
+ "message",
303
+ Buffer.from(
304
+ JSON.stringify({
305
+ id: "rv1",
306
+ type: "revoke",
307
+ ts: Date.now(),
308
+ params: { reason: "test" },
309
+ }),
310
+ ),
311
+ );
312
+ await new Promise((r) => setImmediate(r));
313
+
314
+ expect(writeSpy).toHaveBeenCalledOnce();
315
+ expect(handler).not.toHaveBeenCalled();
316
+ const acks = ws.sent.map((s) => JSON.parse(s));
317
+ expect(acks[0]).toEqual({ id: "rv1", ok: true, result: { acknowledged: true } });
318
+ // Channel should self-stop and refuse further connects.
319
+ expect(ch.isConnected).toBe(false);
320
+
321
+ writeSpy.mockRestore();
322
+ });
323
+ });
324
+
325
+ afterEach(() => {
326
+ FakeWebSocket.instances.length = 0;
327
+ });
@@ -0,0 +1,116 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { ActivityTracker } from "../activity-tracker.js";
6
+ import { buildCrossRoomDigest } from "../cross-room.js";
7
+
8
+ let tmpDir = "";
9
+ let tracker: ActivityTracker;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "daemon-xr-"));
13
+ tracker = new ActivityTracker({ filePath: path.join(tmpDir, "activity.json") });
14
+ });
15
+ afterEach(() => {
16
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("buildCrossRoomDigest", () => {
20
+ it("returns null when there are no other rooms", () => {
21
+ tracker.record({
22
+ agentId: "ag_me",
23
+ roomId: "rm_only",
24
+ topic: null,
25
+ lastInboundPreview: "hi",
26
+ lastSenderKind: "agent",
27
+ lastSender: "ag_peer",
28
+ });
29
+ const digest = buildCrossRoomDigest({
30
+ tracker,
31
+ agentId: "ag_me",
32
+ currentRoomId: "rm_only",
33
+ });
34
+ expect(digest).toBeNull();
35
+ });
36
+
37
+ it("renders up to maxEntries active rooms, newest first, with sender + preview", () => {
38
+ const base = Date.now();
39
+ for (let i = 0; i < 7; i++) {
40
+ tracker.record({
41
+ agentId: "ag_me",
42
+ roomId: `rm_${i}`,
43
+ roomName: `Room${i}`,
44
+ topic: null,
45
+ lastInboundPreview: `msg ${i}`,
46
+ lastSenderKind: "agent",
47
+ lastSender: `ag_p${i}`,
48
+ lastActivityAt: base - i * 60 * 1000,
49
+ });
50
+ }
51
+ const digest = buildCrossRoomDigest({
52
+ tracker,
53
+ agentId: "ag_me",
54
+ currentRoomId: "rm_0",
55
+ maxEntries: 3,
56
+ });
57
+ expect(digest).not.toBeNull();
58
+ expect(digest).toContain("[BotCord Cross-Room Awareness]");
59
+ // 7 rooms recorded (rm_0..rm_6), current is rm_0 → 6 others + 1 current = 7 total.
60
+ expect(digest).toContain("You are currently active in 7 BotCord sessions");
61
+ expect(digest).toContain("Room1 (rm_1)");
62
+ expect(digest).toContain("Room2 (rm_2)");
63
+ expect(digest).toContain("Room3 (rm_3)");
64
+ // maxEntries=3 so Room4+ shouldn't appear
65
+ expect(digest).not.toContain("Room4 (rm_4)");
66
+ expect(digest).toContain("agent ag_p1: msg 1");
67
+ });
68
+
69
+ it("skips entries outside windowMs", () => {
70
+ const base = Date.now();
71
+ tracker.record({
72
+ agentId: "ag_me",
73
+ roomId: "rm_recent",
74
+ topic: null,
75
+ lastInboundPreview: "fresh",
76
+ lastSenderKind: "agent",
77
+ lastSender: "ag_p",
78
+ lastActivityAt: base - 5 * 60 * 1000,
79
+ });
80
+ tracker.record({
81
+ agentId: "ag_me",
82
+ roomId: "rm_stale",
83
+ topic: null,
84
+ lastInboundPreview: "stale",
85
+ lastSenderKind: "agent",
86
+ lastSender: "ag_p",
87
+ lastActivityAt: base - 3 * 60 * 60 * 1000,
88
+ });
89
+ const digest = buildCrossRoomDigest({
90
+ tracker,
91
+ agentId: "ag_me",
92
+ currentRoomId: "rm_somewhere",
93
+ windowMs: 2 * 60 * 60 * 1000,
94
+ });
95
+ expect(digest).toContain("rm_recent");
96
+ expect(digest).not.toContain("rm_stale");
97
+ });
98
+
99
+ it("labels human sender differently from agent", () => {
100
+ tracker.record({
101
+ agentId: "ag_me",
102
+ roomId: "rm_humans",
103
+ topic: null,
104
+ lastInboundPreview: "hi",
105
+ lastSenderKind: "human",
106
+ lastSender: "Alice",
107
+ });
108
+ const digest = buildCrossRoomDigest({
109
+ tracker,
110
+ agentId: "ag_me",
111
+ currentRoomId: "rm_somewhere",
112
+ });
113
+ expect(digest).toContain("human Alice:");
114
+ expect(digest).not.toContain("agent Alice:");
115
+ });
116
+ });