@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,605 @@
1
+ import WebSocket from "ws";
2
+ import {
3
+ BotCordClient,
4
+ buildHubWebSocketUrl,
5
+ defaultCredentialsFile,
6
+ loadStoredCredentials,
7
+ updateCredentialsToken,
8
+ type InboxMessage,
9
+ } from "@botcord/protocol-core";
10
+ import type {
11
+ ChannelAdapter,
12
+ ChannelSendContext,
13
+ ChannelSendResult,
14
+ ChannelStartContext,
15
+ ChannelStatusSnapshot,
16
+ ChannelStopContext,
17
+ ChannelStreamBlockContext,
18
+ GatewayInboundEnvelope,
19
+ GatewayInboundMessage,
20
+ GatewayLogger,
21
+ } from "../index.js";
22
+ import { sanitizeUntrustedContent } from "./sanitize.js";
23
+
24
+ const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
25
+ const KEEPALIVE_INTERVAL = 20_000;
26
+ const MAX_AUTH_FAILURES = 5;
27
+ const SEEN_MESSAGES_CAP = 500;
28
+ const OWNER_CHAT_PREFIX = "rm_oc_";
29
+ const DM_ROOM_PREFIX = "rm_dm_";
30
+
31
+ /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
32
+ export interface BotCordChannelClient {
33
+ ensureToken(): Promise<string>;
34
+ refreshToken(): Promise<string>;
35
+ pollInbox(options?: {
36
+ limit?: number;
37
+ ack?: boolean;
38
+ timeout?: number;
39
+ roomId?: string;
40
+ }): Promise<{ messages: InboxMessage[]; count: number; has_more: boolean }>;
41
+ ackMessages(messageIds: string[]): Promise<void>;
42
+ sendMessage(
43
+ to: string,
44
+ text: string,
45
+ options?: { replyTo?: string; topic?: string },
46
+ ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
47
+ getHubUrl(): string;
48
+ onTokenRefresh?: (token: string, expiresAt: number) => void;
49
+ }
50
+
51
+ /** Factory that returns a ready-to-use BotCord client. Injection point for tests. */
52
+ export type BotCordClientFactory = (input: {
53
+ agentId: string;
54
+ hubBaseUrl?: string;
55
+ credentialsPath?: string;
56
+ }) => BotCordChannelClient;
57
+
58
+ /** Options accepted by `createBotCordChannel()`. */
59
+ export interface BotCordChannelOptions {
60
+ /** Channel instance id from config. */
61
+ id: string;
62
+ /** Gateway `accountId` — matches BotCord `agentId`. */
63
+ accountId: string;
64
+ /** BotCord `agentId` (usually identical to `accountId`). */
65
+ agentId: string;
66
+ /** Override for the credentials JSON path. Defaults to `~/.botcord/credentials/<agentId>.json`. */
67
+ credentialsPath?: string;
68
+ /** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
69
+ hubBaseUrl?: string;
70
+ /** Not used by the WS-only loop today; kept for future polling fallback. */
71
+ pollIntervalMs?: number;
72
+ /** Test hook: supply a pre-built client instead of loading credentials from disk. */
73
+ client?: BotCordChannelClient;
74
+ /** Test hook: supply a client factory. Ignored when `client` is provided. */
75
+ clientFactory?: BotCordClientFactory;
76
+ /**
77
+ * Test hook: override the raw WebSocket constructor. Useful for tests that
78
+ * can't spin up a real WS server.
79
+ */
80
+ webSocketCtor?: typeof WebSocket;
81
+ }
82
+
83
+ /** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
84
+ function defaultClientFactory(input: {
85
+ agentId: string;
86
+ hubBaseUrl?: string;
87
+ credentialsPath?: string;
88
+ }): BotCordChannelClient {
89
+ const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
90
+ const creds = loadStoredCredentials(credFile);
91
+ const client = new BotCordClient({
92
+ hubUrl: input.hubBaseUrl ?? creds.hubUrl,
93
+ agentId: creds.agentId,
94
+ keyId: creds.keyId,
95
+ privateKey: creds.privateKey,
96
+ token: creds.token,
97
+ tokenExpiresAt: creds.tokenExpiresAt,
98
+ });
99
+ client.onTokenRefresh = (token, expiresAt) => {
100
+ try {
101
+ updateCredentialsToken(credFile, token, expiresAt);
102
+ } catch {
103
+ // persistence failures are non-fatal — next refresh will retry.
104
+ }
105
+ };
106
+ return client as unknown as BotCordChannelClient;
107
+ }
108
+
109
+ /**
110
+ * Classify inbound trust tier to decide whether to sanitize text.
111
+ *
112
+ * Mirrors `daemon/src/dispatcher.ts#classifyTrust`: owner-chat rooms
113
+ * (`rm_oc_` prefix) and `dashboard_user_chat` come from the operator and
114
+ * pass through verbatim; everything else gets sanitized before emit.
115
+ */
116
+ function isOwnerTrust(msg: InboxMessage): boolean {
117
+ if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX)) return true;
118
+ if (msg.source_type === "dashboard_user_chat") return true;
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Map `InboxMessage` → `GatewayInboundMessage`. Field origins:
124
+ *
125
+ * id → msg.hub_msg_id (inbox id, what dispatcher currently keys on)
126
+ * channel → options.channelId (the adapter's unique instance id)
127
+ * accountId → options.accountId
128
+ * conversation.id → msg.room_id (required; we skip upstream if missing)
129
+ * conversation.kind → "direct" for rm_dm_ and rm_oc_ rooms, else "group"
130
+ * conversation.title → msg.room_name (daemon uses the same field in logs)
131
+ * conversation.threadId → msg.topic_id ?? msg.topic ?? null
132
+ * sender.id → msg.envelope.from
133
+ * sender.name → msg.source_user_name || undefined
134
+ * sender.kind → "user" when trust==owner or source_type=="dashboard_human_room",
135
+ * else "agent". "system" is not produced by daemon today.
136
+ * text → sanitized msg.text / envelope.payload.text (owner passes verbatim)
137
+ * raw → the full InboxMessage
138
+ * replyTo → msg.envelope.reply_to ?? null
139
+ * mentioned → msg.mentioned ?? false
140
+ * receivedAt → Date.now() (InboxMessage has no timestamp field today)
141
+ * trace.id → msg.hub_msg_id
142
+ * trace.streamable → true only for owner-chat rooms (matches daemon's stream-block rule)
143
+ */
144
+ function normalizeInbox(
145
+ msg: InboxMessage,
146
+ options: { channelId: string; accountId: string },
147
+ ): GatewayInboundMessage | null {
148
+ const env = msg.envelope;
149
+ if (!env) return null;
150
+ if (env.type !== "message") return null;
151
+ if (!msg.room_id) return null;
152
+
153
+ const rawText =
154
+ msg.text ?? (typeof env.payload?.text === "string" ? (env.payload.text as string) : "");
155
+ if (typeof rawText !== "string") return null;
156
+
157
+ const ownerTrust = isOwnerTrust(msg);
158
+ const text = ownerTrust ? rawText : sanitizeUntrustedContent(rawText);
159
+
160
+ const isDm = msg.room_id.startsWith(DM_ROOM_PREFIX);
161
+ const isOwnerChat = msg.room_id.startsWith(OWNER_CHAT_PREFIX);
162
+ const senderKind: "user" | "agent" =
163
+ ownerTrust || msg.source_type === "dashboard_human_room" ? "user" : "agent";
164
+
165
+ const senderName = msg.source_user_name ?? undefined;
166
+ const threadId = msg.topic_id ?? msg.topic ?? null;
167
+ const streamable = isOwnerChat;
168
+
169
+ return {
170
+ id: msg.hub_msg_id,
171
+ channel: options.channelId,
172
+ accountId: options.accountId,
173
+ conversation: {
174
+ id: msg.room_id,
175
+ kind: isDm || isOwnerChat ? "direct" : "group",
176
+ ...(msg.room_name ? { title: msg.room_name } : {}),
177
+ threadId,
178
+ },
179
+ sender: {
180
+ id: env.from,
181
+ ...(senderName ? { name: senderName } : {}),
182
+ kind: senderKind,
183
+ },
184
+ text,
185
+ raw: msg,
186
+ replyTo: env.reply_to ?? null,
187
+ mentioned: msg.mentioned ?? false,
188
+ receivedAt: Date.now(),
189
+ trace: { id: msg.hub_msg_id, streamable },
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Construct a BotCord channel adapter.
195
+ *
196
+ * `start()` connects to Hub WS, drains `/hub/inbox` on every `inbox_update`,
197
+ * normalizes messages, and emits envelopes with a `accept()` ack that commits
198
+ * to Hub. The returned promise stays pending until `abortSignal` fires.
199
+ */
200
+ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAdapter {
201
+ const channelType = "botcord";
202
+ const factory = options.clientFactory ?? defaultClientFactory;
203
+ let clientRef: BotCordChannelClient | null = options.client ?? null;
204
+ const seenMessages = new Set<string>();
205
+ let stopCallback: (() => void) | null = null;
206
+
207
+ let statusSnapshot: ChannelStatusSnapshot = {
208
+ channel: options.id,
209
+ accountId: options.accountId,
210
+ running: false,
211
+ connected: false,
212
+ reconnectAttempts: 0,
213
+ lastError: null,
214
+ };
215
+
216
+ function rememberSeen(hubMsgId: string): boolean {
217
+ if (seenMessages.has(hubMsgId)) return false;
218
+ seenMessages.add(hubMsgId);
219
+ if (seenMessages.size > SEEN_MESSAGES_CAP) {
220
+ const first = seenMessages.values().next().value;
221
+ if (first) seenMessages.delete(first);
222
+ }
223
+ return true;
224
+ }
225
+
226
+ function ensureClient(): BotCordChannelClient {
227
+ if (!clientRef) {
228
+ clientRef = factory({
229
+ agentId: options.agentId,
230
+ hubBaseUrl: options.hubBaseUrl,
231
+ credentialsPath: options.credentialsPath,
232
+ });
233
+ }
234
+ return clientRef;
235
+ }
236
+
237
+ async function drainInbox(
238
+ client: BotCordChannelClient,
239
+ emit: (env: GatewayInboundEnvelope) => Promise<void>,
240
+ log: GatewayLogger,
241
+ ): Promise<void> {
242
+ const resp = await client.pollInbox({ limit: 50, ack: false });
243
+ const msgs = resp.messages ?? [];
244
+ log.info("botcord inbox drained", { count: msgs.length });
245
+ if (msgs.length === 0) return;
246
+
247
+ for (const msg of msgs) {
248
+ if (!rememberSeen(msg.hub_msg_id)) {
249
+ // Already emitted; ack again so Hub stops requeueing.
250
+ try {
251
+ await client.ackMessages([msg.hub_msg_id]);
252
+ } catch (err) {
253
+ log.warn("botcord duplicate ack failed", { err: String(err) });
254
+ }
255
+ continue;
256
+ }
257
+ const normalized = normalizeInbox(msg, {
258
+ channelId: options.id,
259
+ accountId: options.accountId,
260
+ });
261
+ if (!normalized) {
262
+ // Not eligible (wrong type, missing room, etc.) — ack so it drops.
263
+ try {
264
+ await client.ackMessages([msg.hub_msg_id]);
265
+ } catch (err) {
266
+ log.warn("botcord skip ack failed", { err: String(err) });
267
+ }
268
+ continue;
269
+ }
270
+ const envelope: GatewayInboundEnvelope = {
271
+ message: normalized,
272
+ ack: {
273
+ accept: async () => {
274
+ try {
275
+ await client.ackMessages([msg.hub_msg_id]);
276
+ } catch (err) {
277
+ log.warn("botcord ack failed — relying on seen-cache dedup", {
278
+ hubMsgId: msg.hub_msg_id,
279
+ err: String(err),
280
+ });
281
+ }
282
+ },
283
+ },
284
+ };
285
+ try {
286
+ await emit(envelope);
287
+ } catch (err) {
288
+ log.error("botcord emit threw", {
289
+ hubMsgId: msg.hub_msg_id,
290
+ err: String(err),
291
+ });
292
+ }
293
+ }
294
+ }
295
+
296
+ function startWsLoop(
297
+ client: BotCordChannelClient,
298
+ ctx: ChannelStartContext,
299
+ ): Promise<void> {
300
+ const { abortSignal, log, emit, setStatus } = ctx;
301
+ const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
302
+ const wsCtor = options.webSocketCtor ?? WebSocket;
303
+
304
+ let ws: WebSocket | null = null;
305
+ let reconnectTimer: NodeJS.Timeout | null = null;
306
+ let keepaliveTimer: NodeJS.Timeout | null = null;
307
+ let reconnectAttempt = 0;
308
+ let consecutiveAuthFailures = 0;
309
+ let running = true;
310
+ let processing = false;
311
+ let pendingUpdate = false;
312
+ let pendingRefresh: Promise<unknown> | null = null;
313
+ let resolveLoop: (() => void) | null = null;
314
+
315
+ const done = new Promise<void>((resolve) => {
316
+ resolveLoop = resolve;
317
+ });
318
+
319
+ function clearTimers() {
320
+ if (reconnectTimer) {
321
+ clearTimeout(reconnectTimer);
322
+ reconnectTimer = null;
323
+ }
324
+ if (keepaliveTimer) {
325
+ clearInterval(keepaliveTimer);
326
+ keepaliveTimer = null;
327
+ }
328
+ }
329
+
330
+ function markStatus(patch: Partial<ChannelStatusSnapshot>) {
331
+ statusSnapshot = { ...statusSnapshot, ...patch };
332
+ setStatus(patch);
333
+ }
334
+
335
+ async function fireInbox() {
336
+ if (processing) {
337
+ pendingUpdate = true;
338
+ return;
339
+ }
340
+ processing = true;
341
+ try {
342
+ do {
343
+ pendingUpdate = false;
344
+ await drainInbox(client, emit, log);
345
+ } while (pendingUpdate && running);
346
+ } catch (err) {
347
+ log.error("botcord inbox drain failed", { err: String(err) });
348
+ } finally {
349
+ processing = false;
350
+ }
351
+ }
352
+
353
+ function scheduleReconnect() {
354
+ if (!running) return;
355
+ const delay =
356
+ RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
357
+ reconnectAttempt += 1;
358
+ markStatus({
359
+ connected: false,
360
+ restartPending: true,
361
+ reconnectAttempts: reconnectAttempt,
362
+ });
363
+ log.info("botcord ws reconnect scheduled", { delayMs: delay, attempt: reconnectAttempt });
364
+ reconnectTimer = setTimeout(() => {
365
+ reconnectTimer = null;
366
+ void connect();
367
+ }, delay);
368
+ }
369
+
370
+ async function connect() {
371
+ if (!running) return;
372
+ markStatus({ connected: false, restartPending: false });
373
+ if (pendingRefresh) {
374
+ try {
375
+ await pendingRefresh;
376
+ } catch {
377
+ // already logged by scheduler
378
+ } finally {
379
+ pendingRefresh = null;
380
+ }
381
+ }
382
+ let token: string;
383
+ try {
384
+ token = await client.ensureToken();
385
+ } catch (err) {
386
+ log.error("botcord ws token refresh failed", { err: String(err) });
387
+ markStatus({ lastError: String(err) });
388
+ scheduleReconnect();
389
+ return;
390
+ }
391
+
392
+ const url = buildHubWebSocketUrl(hubUrl);
393
+ log.info("botcord ws connecting", { url, agentId: options.agentId });
394
+
395
+ try {
396
+ ws = new wsCtor(url);
397
+ } catch (err) {
398
+ log.error("botcord ws construct failed", { err: String(err) });
399
+ markStatus({ lastError: String(err) });
400
+ scheduleReconnect();
401
+ return;
402
+ }
403
+
404
+ ws.on("open", () => {
405
+ ws!.send(JSON.stringify({ type: "auth", token }));
406
+ });
407
+
408
+ ws.on("message", (data: WebSocket.RawData) => {
409
+ let msg: { type?: string; agent_id?: string } | null = null;
410
+ try {
411
+ msg = JSON.parse(String(data));
412
+ } catch {
413
+ return;
414
+ }
415
+ if (!msg || typeof msg.type !== "string") return;
416
+ if (msg.type === "auth_ok") {
417
+ reconnectAttempt = 0;
418
+ consecutiveAuthFailures = 0;
419
+ markStatus({
420
+ running: true,
421
+ connected: true,
422
+ reconnectAttempts: 0,
423
+ lastStartAt: Date.now(),
424
+ lastError: null,
425
+ });
426
+ log.info("botcord ws authenticated", { agentId: msg.agent_id });
427
+ void fireInbox();
428
+ keepaliveTimer = setInterval(() => {
429
+ if (ws && ws.readyState === WebSocket.OPEN) {
430
+ try {
431
+ ws.send(JSON.stringify({ type: "ping" }));
432
+ } catch {
433
+ // ignore
434
+ }
435
+ }
436
+ }, KEEPALIVE_INTERVAL);
437
+ } else if (msg.type === "inbox_update") {
438
+ log.info("botcord ws inbox_update received");
439
+ void fireInbox();
440
+ } else if (msg.type === "heartbeat" || msg.type === "pong") {
441
+ // no-op
442
+ } else if (msg.type === "error" || msg.type === "auth_failed") {
443
+ log.warn("botcord ws server error", { msg });
444
+ }
445
+ });
446
+
447
+ ws.on("close", (code: number, reason: Buffer) => {
448
+ const reasonStr = reason?.toString() || "";
449
+ log.info("botcord ws closed", { code, reason: reasonStr });
450
+ clearTimers();
451
+ markStatus({ connected: false });
452
+ if (!running) {
453
+ if (resolveLoop) {
454
+ const r = resolveLoop;
455
+ resolveLoop = null;
456
+ r();
457
+ }
458
+ return;
459
+ }
460
+ if (code === 4001) {
461
+ consecutiveAuthFailures += 1;
462
+ if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
463
+ log.error("botcord ws auth failing persistently — giving up reconnects", {
464
+ failures: consecutiveAuthFailures,
465
+ });
466
+ running = false;
467
+ markStatus({
468
+ running: false,
469
+ connected: false,
470
+ lastStopAt: Date.now(),
471
+ lastError: "auth failed repeatedly",
472
+ });
473
+ if (resolveLoop) {
474
+ const r = resolveLoop;
475
+ resolveLoop = null;
476
+ r();
477
+ }
478
+ return;
479
+ }
480
+ pendingRefresh = client
481
+ .refreshToken()
482
+ .catch((err) => log.error("botcord ws forced refresh failed", { err: String(err) }));
483
+ }
484
+ scheduleReconnect();
485
+ });
486
+
487
+ ws.on("error", (err: Error) => {
488
+ log.warn("botcord ws error", { err: String(err) });
489
+ markStatus({ lastError: String(err) });
490
+ });
491
+ }
492
+
493
+ function stopLoop() {
494
+ if (!running) return;
495
+ running = false;
496
+ clearTimers();
497
+ markStatus({
498
+ running: false,
499
+ connected: false,
500
+ lastStopAt: Date.now(),
501
+ });
502
+ if (ws) {
503
+ try {
504
+ ws.close();
505
+ } catch {
506
+ // ignore
507
+ }
508
+ ws = null;
509
+ }
510
+ if (resolveLoop) {
511
+ const r = resolveLoop;
512
+ resolveLoop = null;
513
+ r();
514
+ }
515
+ }
516
+
517
+ stopCallback = stopLoop;
518
+ abortSignal.addEventListener("abort", stopLoop, { once: true });
519
+ void connect();
520
+ return done;
521
+ }
522
+
523
+ const adapter: ChannelAdapter = {
524
+ id: options.id,
525
+ type: channelType,
526
+
527
+ async start(ctx: ChannelStartContext): Promise<void> {
528
+ const client = ensureClient();
529
+ // Only patch fields owned by the adapter; the manager is the single
530
+ // writer for `channel` (== adapter.id) and `accountId`.
531
+ const patch: Partial<ChannelStatusSnapshot> = {
532
+ running: true,
533
+ connected: false,
534
+ reconnectAttempts: 0,
535
+ lastStartAt: Date.now(),
536
+ lastError: null,
537
+ };
538
+ statusSnapshot = { ...statusSnapshot, ...patch };
539
+ ctx.setStatus(patch);
540
+ await startWsLoop(client, ctx);
541
+ },
542
+
543
+ async stop(_ctx: ChannelStopContext): Promise<void> {
544
+ if (stopCallback) {
545
+ stopCallback();
546
+ stopCallback = null;
547
+ }
548
+ },
549
+
550
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
551
+ const client = ensureClient();
552
+ const { message } = ctx;
553
+ const options: { replyTo?: string; topic?: string } = {};
554
+ if (message.replyTo) options.replyTo = message.replyTo;
555
+ if (message.threadId) options.topic = message.threadId;
556
+ const resp = await client.sendMessage(message.conversationId, message.text, options);
557
+ const providerMessageId =
558
+ (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
559
+ (resp && typeof (resp as { message_id?: unknown }).message_id === "string"
560
+ ? (resp as { message_id: string }).message_id
561
+ : null);
562
+ return { providerMessageId: providerMessageId ?? null };
563
+ },
564
+
565
+ async streamBlock(ctx: ChannelStreamBlockContext): Promise<void> {
566
+ const client = ensureClient();
567
+ const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
568
+ try {
569
+ const token = await client.ensureToken();
570
+ const block = ctx.block as { raw?: unknown; kind?: string; seq?: number } | undefined;
571
+ const resp = await fetch(`${hubUrl}/hub/stream-block`, {
572
+ method: "POST",
573
+ headers: {
574
+ "Content-Type": "application/json",
575
+ Authorization: `Bearer ${token}`,
576
+ },
577
+ body: JSON.stringify({
578
+ trace_id: ctx.traceId,
579
+ seq: typeof block?.seq === "number" ? block.seq : 0,
580
+ block: ctx.block,
581
+ }),
582
+ signal: AbortSignal.timeout(10_000),
583
+ });
584
+ if (!resp.ok && resp.status !== 204) {
585
+ const body = await resp.text().catch(() => "");
586
+ ctx.log.warn("botcord stream-block non-ok", {
587
+ status: resp.status,
588
+ body: body.slice(0, 200),
589
+ });
590
+ }
591
+ } catch (err) {
592
+ ctx.log.warn("botcord stream-block failed", { err: String(err) });
593
+ }
594
+ },
595
+
596
+ status(): ChannelStatusSnapshot {
597
+ return { ...statusSnapshot };
598
+ },
599
+ };
600
+
601
+ return adapter;
602
+ }
603
+
604
+ // Re-export the normalizer for tests that want to exercise it directly.
605
+ export { normalizeInbox as __normalizeInboxForTests };
@@ -0,0 +1,6 @@
1
+ export { createBotCordChannel } from "./botcord.js";
2
+ export type {
3
+ BotCordChannelClient,
4
+ BotCordChannelOptions,
5
+ BotCordClientFactory,
6
+ } from "./botcord.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sanitize untrusted inbound content before handing it off to a local runtime.
3
+ *
4
+ * Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
5
+ * does not depend back on the daemon package. Keep these two files in sync —
6
+ * any new structural marker added in one place should be mirrored in the other.
7
+ *
8
+ * Neutralizes:
9
+ * - BotCord structural markers the channel itself emits (so peers can't forge them).
10
+ * - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
11
+ * - Wrapper XML tags the channel uses to frame inbound content
12
+ * (<agent-message>, <human-message>, <room-rule>).
13
+ */
14
+
15
+ export function sanitizeUntrustedContent(text: string): string {
16
+ let s = text;
17
+ s = s.replace(
18
+ /<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
19
+ "[⚠ stripped: agent-message tag]",
20
+ );
21
+ s = s.replace(
22
+ /<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
23
+ "[⚠ stripped: human-message tag]",
24
+ );
25
+ s = s.replace(
26
+ /<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi,
27
+ "[⚠ stripped: room-rule tag]",
28
+ );
29
+
30
+ return s
31
+ .split(/\r?\n/)
32
+ .map((line) => {
33
+ let l = line;
34
+ l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
35
+ l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
36
+ l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
37
+ l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
38
+ l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
39
+ const head = String(label).split(":")[0].trim() || String(label).trim();
40
+ return `[⚠ fake: BotCord ${head}]`;
41
+ });
42
+ l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
43
+ l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
44
+ l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
45
+ l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
46
+ l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
47
+ l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
48
+ l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
49
+ return l;
50
+ })
51
+ .join("\n");
52
+ }
53
+
54
+ /**
55
+ * Sanitize a sender label so it's safe to embed inside
56
+ * `<agent-message sender="...">`. Must not contain newlines, structural
57
+ * markers, or characters that could break the XML attribute boundary.
58
+ */
59
+ export function sanitizeSenderName(name: string): string {
60
+ return name
61
+ .replace(/[\n\r]/g, " ")
62
+ .replace(/\[/g, "⟦")
63
+ .replace(/\]/g, "⟧")
64
+ .replace(/"/g, "'")
65
+ .replace(/</g, "<")
66
+ .replace(/>/g, ">")
67
+ .slice(0, 100);
68
+ }