@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.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { BotCordClient, buildHubWebSocketUrl, defaultCredentialsFile, loadStoredCredentials, updateCredentialsToken, } from "@botcord/protocol-core";
|
|
3
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
4
|
+
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
5
|
+
const KEEPALIVE_INTERVAL = 20_000;
|
|
6
|
+
const MAX_AUTH_FAILURES = 5;
|
|
7
|
+
const SEEN_MESSAGES_CAP = 500;
|
|
8
|
+
const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
9
|
+
const DM_ROOM_PREFIX = "rm_dm_";
|
|
10
|
+
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
11
|
+
function defaultClientFactory(input) {
|
|
12
|
+
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
13
|
+
const creds = loadStoredCredentials(credFile);
|
|
14
|
+
const client = new BotCordClient({
|
|
15
|
+
hubUrl: input.hubBaseUrl ?? creds.hubUrl,
|
|
16
|
+
agentId: creds.agentId,
|
|
17
|
+
keyId: creds.keyId,
|
|
18
|
+
privateKey: creds.privateKey,
|
|
19
|
+
token: creds.token,
|
|
20
|
+
tokenExpiresAt: creds.tokenExpiresAt,
|
|
21
|
+
});
|
|
22
|
+
client.onTokenRefresh = (token, expiresAt) => {
|
|
23
|
+
try {
|
|
24
|
+
updateCredentialsToken(credFile, token, expiresAt);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// persistence failures are non-fatal — next refresh will retry.
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return client;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Classify inbound trust tier to decide whether to sanitize text.
|
|
34
|
+
*
|
|
35
|
+
* Mirrors `daemon/src/dispatcher.ts#classifyTrust`: owner-chat rooms
|
|
36
|
+
* (`rm_oc_` prefix) and `dashboard_user_chat` come from the operator and
|
|
37
|
+
* pass through verbatim; everything else gets sanitized before emit.
|
|
38
|
+
*/
|
|
39
|
+
function isOwnerTrust(msg) {
|
|
40
|
+
if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX))
|
|
41
|
+
return true;
|
|
42
|
+
if (msg.source_type === "dashboard_user_chat")
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Map `InboxMessage` → `GatewayInboundMessage`. Field origins:
|
|
48
|
+
*
|
|
49
|
+
* id → msg.hub_msg_id (inbox id, what dispatcher currently keys on)
|
|
50
|
+
* channel → options.channelId (the adapter's unique instance id)
|
|
51
|
+
* accountId → options.accountId
|
|
52
|
+
* conversation.id → msg.room_id (required; we skip upstream if missing)
|
|
53
|
+
* conversation.kind → "direct" for rm_dm_ and rm_oc_ rooms, else "group"
|
|
54
|
+
* conversation.title → msg.room_name (daemon uses the same field in logs)
|
|
55
|
+
* conversation.threadId → msg.topic_id ?? msg.topic ?? null
|
|
56
|
+
* sender.id → msg.envelope.from
|
|
57
|
+
* sender.name → msg.source_user_name || undefined
|
|
58
|
+
* sender.kind → "user" when trust==owner or source_type=="dashboard_human_room",
|
|
59
|
+
* else "agent". "system" is not produced by daemon today.
|
|
60
|
+
* text → sanitized msg.text / envelope.payload.text (owner passes verbatim)
|
|
61
|
+
* raw → the full InboxMessage
|
|
62
|
+
* replyTo → msg.envelope.reply_to ?? null
|
|
63
|
+
* mentioned → msg.mentioned ?? false
|
|
64
|
+
* receivedAt → Date.now() (InboxMessage has no timestamp field today)
|
|
65
|
+
* trace.id → msg.hub_msg_id
|
|
66
|
+
* trace.streamable → true only for owner-chat rooms (matches daemon's stream-block rule)
|
|
67
|
+
*/
|
|
68
|
+
function normalizeInbox(msg, options) {
|
|
69
|
+
const env = msg.envelope;
|
|
70
|
+
if (!env)
|
|
71
|
+
return null;
|
|
72
|
+
if (env.type !== "message")
|
|
73
|
+
return null;
|
|
74
|
+
if (!msg.room_id)
|
|
75
|
+
return null;
|
|
76
|
+
const rawText = msg.text ?? (typeof env.payload?.text === "string" ? env.payload.text : "");
|
|
77
|
+
if (typeof rawText !== "string")
|
|
78
|
+
return null;
|
|
79
|
+
const ownerTrust = isOwnerTrust(msg);
|
|
80
|
+
const text = ownerTrust ? rawText : sanitizeUntrustedContent(rawText);
|
|
81
|
+
const isDm = msg.room_id.startsWith(DM_ROOM_PREFIX);
|
|
82
|
+
const isOwnerChat = msg.room_id.startsWith(OWNER_CHAT_PREFIX);
|
|
83
|
+
const senderKind = ownerTrust || msg.source_type === "dashboard_human_room" ? "user" : "agent";
|
|
84
|
+
const senderName = msg.source_user_name ?? undefined;
|
|
85
|
+
const threadId = msg.topic_id ?? msg.topic ?? null;
|
|
86
|
+
const streamable = isOwnerChat;
|
|
87
|
+
return {
|
|
88
|
+
id: msg.hub_msg_id,
|
|
89
|
+
channel: options.channelId,
|
|
90
|
+
accountId: options.accountId,
|
|
91
|
+
conversation: {
|
|
92
|
+
id: msg.room_id,
|
|
93
|
+
kind: isDm || isOwnerChat ? "direct" : "group",
|
|
94
|
+
...(msg.room_name ? { title: msg.room_name } : {}),
|
|
95
|
+
threadId,
|
|
96
|
+
},
|
|
97
|
+
sender: {
|
|
98
|
+
id: env.from,
|
|
99
|
+
...(senderName ? { name: senderName } : {}),
|
|
100
|
+
kind: senderKind,
|
|
101
|
+
},
|
|
102
|
+
text,
|
|
103
|
+
raw: msg,
|
|
104
|
+
replyTo: env.reply_to ?? null,
|
|
105
|
+
mentioned: msg.mentioned ?? false,
|
|
106
|
+
receivedAt: Date.now(),
|
|
107
|
+
trace: { id: msg.hub_msg_id, streamable },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Construct a BotCord channel adapter.
|
|
112
|
+
*
|
|
113
|
+
* `start()` connects to Hub WS, drains `/hub/inbox` on every `inbox_update`,
|
|
114
|
+
* normalizes messages, and emits envelopes with a `accept()` ack that commits
|
|
115
|
+
* to Hub. The returned promise stays pending until `abortSignal` fires.
|
|
116
|
+
*/
|
|
117
|
+
export function createBotCordChannel(options) {
|
|
118
|
+
const channelType = "botcord";
|
|
119
|
+
const factory = options.clientFactory ?? defaultClientFactory;
|
|
120
|
+
let clientRef = options.client ?? null;
|
|
121
|
+
const seenMessages = new Set();
|
|
122
|
+
let stopCallback = null;
|
|
123
|
+
let statusSnapshot = {
|
|
124
|
+
channel: options.id,
|
|
125
|
+
accountId: options.accountId,
|
|
126
|
+
running: false,
|
|
127
|
+
connected: false,
|
|
128
|
+
reconnectAttempts: 0,
|
|
129
|
+
lastError: null,
|
|
130
|
+
};
|
|
131
|
+
function rememberSeen(hubMsgId) {
|
|
132
|
+
if (seenMessages.has(hubMsgId))
|
|
133
|
+
return false;
|
|
134
|
+
seenMessages.add(hubMsgId);
|
|
135
|
+
if (seenMessages.size > SEEN_MESSAGES_CAP) {
|
|
136
|
+
const first = seenMessages.values().next().value;
|
|
137
|
+
if (first)
|
|
138
|
+
seenMessages.delete(first);
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function ensureClient() {
|
|
143
|
+
if (!clientRef) {
|
|
144
|
+
clientRef = factory({
|
|
145
|
+
agentId: options.agentId,
|
|
146
|
+
hubBaseUrl: options.hubBaseUrl,
|
|
147
|
+
credentialsPath: options.credentialsPath,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return clientRef;
|
|
151
|
+
}
|
|
152
|
+
async function drainInbox(client, emit, log) {
|
|
153
|
+
const resp = await client.pollInbox({ limit: 50, ack: false });
|
|
154
|
+
const msgs = resp.messages ?? [];
|
|
155
|
+
log.info("botcord inbox drained", { count: msgs.length });
|
|
156
|
+
if (msgs.length === 0)
|
|
157
|
+
return;
|
|
158
|
+
for (const msg of msgs) {
|
|
159
|
+
if (!rememberSeen(msg.hub_msg_id)) {
|
|
160
|
+
// Already emitted; ack again so Hub stops requeueing.
|
|
161
|
+
try {
|
|
162
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
log.warn("botcord duplicate ack failed", { err: String(err) });
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const normalized = normalizeInbox(msg, {
|
|
170
|
+
channelId: options.id,
|
|
171
|
+
accountId: options.accountId,
|
|
172
|
+
});
|
|
173
|
+
if (!normalized) {
|
|
174
|
+
// Not eligible (wrong type, missing room, etc.) — ack so it drops.
|
|
175
|
+
try {
|
|
176
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
log.warn("botcord skip ack failed", { err: String(err) });
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const envelope = {
|
|
184
|
+
message: normalized,
|
|
185
|
+
ack: {
|
|
186
|
+
accept: async () => {
|
|
187
|
+
try {
|
|
188
|
+
await client.ackMessages([msg.hub_msg_id]);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
log.warn("botcord ack failed — relying on seen-cache dedup", {
|
|
192
|
+
hubMsgId: msg.hub_msg_id,
|
|
193
|
+
err: String(err),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
await emit(envelope);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
log.error("botcord emit threw", {
|
|
204
|
+
hubMsgId: msg.hub_msg_id,
|
|
205
|
+
err: String(err),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function startWsLoop(client, ctx) {
|
|
211
|
+
const { abortSignal, log, emit, setStatus } = ctx;
|
|
212
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
213
|
+
const wsCtor = options.webSocketCtor ?? WebSocket;
|
|
214
|
+
let ws = null;
|
|
215
|
+
let reconnectTimer = null;
|
|
216
|
+
let keepaliveTimer = null;
|
|
217
|
+
let reconnectAttempt = 0;
|
|
218
|
+
let consecutiveAuthFailures = 0;
|
|
219
|
+
let running = true;
|
|
220
|
+
let processing = false;
|
|
221
|
+
let pendingUpdate = false;
|
|
222
|
+
let pendingRefresh = null;
|
|
223
|
+
let resolveLoop = null;
|
|
224
|
+
const done = new Promise((resolve) => {
|
|
225
|
+
resolveLoop = resolve;
|
|
226
|
+
});
|
|
227
|
+
function clearTimers() {
|
|
228
|
+
if (reconnectTimer) {
|
|
229
|
+
clearTimeout(reconnectTimer);
|
|
230
|
+
reconnectTimer = null;
|
|
231
|
+
}
|
|
232
|
+
if (keepaliveTimer) {
|
|
233
|
+
clearInterval(keepaliveTimer);
|
|
234
|
+
keepaliveTimer = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function markStatus(patch) {
|
|
238
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
239
|
+
setStatus(patch);
|
|
240
|
+
}
|
|
241
|
+
async function fireInbox() {
|
|
242
|
+
if (processing) {
|
|
243
|
+
pendingUpdate = true;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
processing = true;
|
|
247
|
+
try {
|
|
248
|
+
do {
|
|
249
|
+
pendingUpdate = false;
|
|
250
|
+
await drainInbox(client, emit, log);
|
|
251
|
+
} while (pendingUpdate && running);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
log.error("botcord inbox drain failed", { err: String(err) });
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
processing = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function scheduleReconnect() {
|
|
261
|
+
if (!running)
|
|
262
|
+
return;
|
|
263
|
+
const delay = RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
|
|
264
|
+
reconnectAttempt += 1;
|
|
265
|
+
markStatus({
|
|
266
|
+
connected: false,
|
|
267
|
+
restartPending: true,
|
|
268
|
+
reconnectAttempts: reconnectAttempt,
|
|
269
|
+
});
|
|
270
|
+
log.info("botcord ws reconnect scheduled", { delayMs: delay, attempt: reconnectAttempt });
|
|
271
|
+
reconnectTimer = setTimeout(() => {
|
|
272
|
+
reconnectTimer = null;
|
|
273
|
+
void connect();
|
|
274
|
+
}, delay);
|
|
275
|
+
}
|
|
276
|
+
async function connect() {
|
|
277
|
+
if (!running)
|
|
278
|
+
return;
|
|
279
|
+
markStatus({ connected: false, restartPending: false });
|
|
280
|
+
if (pendingRefresh) {
|
|
281
|
+
try {
|
|
282
|
+
await pendingRefresh;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// already logged by scheduler
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
pendingRefresh = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
let token;
|
|
292
|
+
try {
|
|
293
|
+
token = await client.ensureToken();
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
log.error("botcord ws token refresh failed", { err: String(err) });
|
|
297
|
+
markStatus({ lastError: String(err) });
|
|
298
|
+
scheduleReconnect();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const url = buildHubWebSocketUrl(hubUrl);
|
|
302
|
+
log.info("botcord ws connecting", { url, agentId: options.agentId });
|
|
303
|
+
try {
|
|
304
|
+
ws = new wsCtor(url);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
log.error("botcord ws construct failed", { err: String(err) });
|
|
308
|
+
markStatus({ lastError: String(err) });
|
|
309
|
+
scheduleReconnect();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
ws.on("open", () => {
|
|
313
|
+
ws.send(JSON.stringify({ type: "auth", token }));
|
|
314
|
+
});
|
|
315
|
+
ws.on("message", (data) => {
|
|
316
|
+
let msg = null;
|
|
317
|
+
try {
|
|
318
|
+
msg = JSON.parse(String(data));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!msg || typeof msg.type !== "string")
|
|
324
|
+
return;
|
|
325
|
+
if (msg.type === "auth_ok") {
|
|
326
|
+
reconnectAttempt = 0;
|
|
327
|
+
consecutiveAuthFailures = 0;
|
|
328
|
+
markStatus({
|
|
329
|
+
running: true,
|
|
330
|
+
connected: true,
|
|
331
|
+
reconnectAttempts: 0,
|
|
332
|
+
lastStartAt: Date.now(),
|
|
333
|
+
lastError: null,
|
|
334
|
+
});
|
|
335
|
+
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
336
|
+
void fireInbox();
|
|
337
|
+
keepaliveTimer = setInterval(() => {
|
|
338
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
339
|
+
try {
|
|
340
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// ignore
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}, KEEPALIVE_INTERVAL);
|
|
347
|
+
}
|
|
348
|
+
else if (msg.type === "inbox_update") {
|
|
349
|
+
log.info("botcord ws inbox_update received");
|
|
350
|
+
void fireInbox();
|
|
351
|
+
}
|
|
352
|
+
else if (msg.type === "heartbeat" || msg.type === "pong") {
|
|
353
|
+
// no-op
|
|
354
|
+
}
|
|
355
|
+
else if (msg.type === "error" || msg.type === "auth_failed") {
|
|
356
|
+
log.warn("botcord ws server error", { msg });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
ws.on("close", (code, reason) => {
|
|
360
|
+
const reasonStr = reason?.toString() || "";
|
|
361
|
+
log.info("botcord ws closed", { code, reason: reasonStr });
|
|
362
|
+
clearTimers();
|
|
363
|
+
markStatus({ connected: false });
|
|
364
|
+
if (!running) {
|
|
365
|
+
if (resolveLoop) {
|
|
366
|
+
const r = resolveLoop;
|
|
367
|
+
resolveLoop = null;
|
|
368
|
+
r();
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (code === 4001) {
|
|
373
|
+
consecutiveAuthFailures += 1;
|
|
374
|
+
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
|
375
|
+
log.error("botcord ws auth failing persistently — giving up reconnects", {
|
|
376
|
+
failures: consecutiveAuthFailures,
|
|
377
|
+
});
|
|
378
|
+
running = false;
|
|
379
|
+
markStatus({
|
|
380
|
+
running: false,
|
|
381
|
+
connected: false,
|
|
382
|
+
lastStopAt: Date.now(),
|
|
383
|
+
lastError: "auth failed repeatedly",
|
|
384
|
+
});
|
|
385
|
+
if (resolveLoop) {
|
|
386
|
+
const r = resolveLoop;
|
|
387
|
+
resolveLoop = null;
|
|
388
|
+
r();
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
pendingRefresh = client
|
|
393
|
+
.refreshToken()
|
|
394
|
+
.catch((err) => log.error("botcord ws forced refresh failed", { err: String(err) }));
|
|
395
|
+
}
|
|
396
|
+
scheduleReconnect();
|
|
397
|
+
});
|
|
398
|
+
ws.on("error", (err) => {
|
|
399
|
+
log.warn("botcord ws error", { err: String(err) });
|
|
400
|
+
markStatus({ lastError: String(err) });
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function stopLoop() {
|
|
404
|
+
if (!running)
|
|
405
|
+
return;
|
|
406
|
+
running = false;
|
|
407
|
+
clearTimers();
|
|
408
|
+
markStatus({
|
|
409
|
+
running: false,
|
|
410
|
+
connected: false,
|
|
411
|
+
lastStopAt: Date.now(),
|
|
412
|
+
});
|
|
413
|
+
if (ws) {
|
|
414
|
+
try {
|
|
415
|
+
ws.close();
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// ignore
|
|
419
|
+
}
|
|
420
|
+
ws = null;
|
|
421
|
+
}
|
|
422
|
+
if (resolveLoop) {
|
|
423
|
+
const r = resolveLoop;
|
|
424
|
+
resolveLoop = null;
|
|
425
|
+
r();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
stopCallback = stopLoop;
|
|
429
|
+
abortSignal.addEventListener("abort", stopLoop, { once: true });
|
|
430
|
+
void connect();
|
|
431
|
+
return done;
|
|
432
|
+
}
|
|
433
|
+
const adapter = {
|
|
434
|
+
id: options.id,
|
|
435
|
+
type: channelType,
|
|
436
|
+
async start(ctx) {
|
|
437
|
+
const client = ensureClient();
|
|
438
|
+
// Only patch fields owned by the adapter; the manager is the single
|
|
439
|
+
// writer for `channel` (== adapter.id) and `accountId`.
|
|
440
|
+
const patch = {
|
|
441
|
+
running: true,
|
|
442
|
+
connected: false,
|
|
443
|
+
reconnectAttempts: 0,
|
|
444
|
+
lastStartAt: Date.now(),
|
|
445
|
+
lastError: null,
|
|
446
|
+
};
|
|
447
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
448
|
+
ctx.setStatus(patch);
|
|
449
|
+
await startWsLoop(client, ctx);
|
|
450
|
+
},
|
|
451
|
+
async stop(_ctx) {
|
|
452
|
+
if (stopCallback) {
|
|
453
|
+
stopCallback();
|
|
454
|
+
stopCallback = null;
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
async send(ctx) {
|
|
458
|
+
const client = ensureClient();
|
|
459
|
+
const { message } = ctx;
|
|
460
|
+
const options = {};
|
|
461
|
+
if (message.replyTo)
|
|
462
|
+
options.replyTo = message.replyTo;
|
|
463
|
+
if (message.threadId)
|
|
464
|
+
options.topic = message.threadId;
|
|
465
|
+
const resp = await client.sendMessage(message.conversationId, message.text, options);
|
|
466
|
+
const providerMessageId = (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
|
|
467
|
+
(resp && typeof resp.message_id === "string"
|
|
468
|
+
? resp.message_id
|
|
469
|
+
: null);
|
|
470
|
+
return { providerMessageId: providerMessageId ?? null };
|
|
471
|
+
},
|
|
472
|
+
async streamBlock(ctx) {
|
|
473
|
+
const client = ensureClient();
|
|
474
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
475
|
+
try {
|
|
476
|
+
const token = await client.ensureToken();
|
|
477
|
+
const block = ctx.block;
|
|
478
|
+
const resp = await fetch(`${hubUrl}/hub/stream-block`, {
|
|
479
|
+
method: "POST",
|
|
480
|
+
headers: {
|
|
481
|
+
"Content-Type": "application/json",
|
|
482
|
+
Authorization: `Bearer ${token}`,
|
|
483
|
+
},
|
|
484
|
+
body: JSON.stringify({
|
|
485
|
+
trace_id: ctx.traceId,
|
|
486
|
+
seq: typeof block?.seq === "number" ? block.seq : 0,
|
|
487
|
+
block: ctx.block,
|
|
488
|
+
}),
|
|
489
|
+
signal: AbortSignal.timeout(10_000),
|
|
490
|
+
});
|
|
491
|
+
if (!resp.ok && resp.status !== 204) {
|
|
492
|
+
const body = await resp.text().catch(() => "");
|
|
493
|
+
ctx.log.warn("botcord stream-block non-ok", {
|
|
494
|
+
status: resp.status,
|
|
495
|
+
body: body.slice(0, 200),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
ctx.log.warn("botcord stream-block failed", { err: String(err) });
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
status() {
|
|
504
|
+
return { ...statusSnapshot };
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
return adapter;
|
|
508
|
+
}
|
|
509
|
+
// Re-export the normalizer for tests that want to exercise it directly.
|
|
510
|
+
export { normalizeInbox as __normalizeInboxForTests };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createBotCordChannel } from "./botcord.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export declare function sanitizeUntrustedContent(text: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Sanitize a sender label so it's safe to embed inside
|
|
17
|
+
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
18
|
+
* markers, or characters that could break the XML attribute boundary.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sanitizeSenderName(name: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
export function sanitizeUntrustedContent(text) {
|
|
15
|
+
let s = text;
|
|
16
|
+
s = s.replace(/<\/?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, "[⚠ stripped: agent-message tag]");
|
|
17
|
+
s = s.replace(/<\/?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, "[⚠ stripped: human-message tag]");
|
|
18
|
+
s = s.replace(/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi, "[⚠ stripped: room-rule tag]");
|
|
19
|
+
return s
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((line) => {
|
|
22
|
+
let l = line;
|
|
23
|
+
l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
|
|
24
|
+
l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
|
|
25
|
+
l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
|
|
26
|
+
l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
|
|
27
|
+
l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
|
|
28
|
+
const head = String(label).split(":")[0].trim() || String(label).trim();
|
|
29
|
+
return `[⚠ fake: BotCord ${head}]`;
|
|
30
|
+
});
|
|
31
|
+
l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
|
|
32
|
+
l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
|
|
33
|
+
l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
|
|
34
|
+
l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
|
|
35
|
+
l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
|
|
36
|
+
l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
|
|
37
|
+
l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
|
|
38
|
+
return l;
|
|
39
|
+
})
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Sanitize a sender label so it's safe to embed inside
|
|
44
|
+
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
45
|
+
* markers, or characters that could break the XML attribute boundary.
|
|
46
|
+
*/
|
|
47
|
+
export function sanitizeSenderName(name) {
|
|
48
|
+
return name
|
|
49
|
+
.replace(/[\n\r]/g, " ")
|
|
50
|
+
.replace(/\[/g, "⟦")
|
|
51
|
+
.replace(/\]/g, "⟧")
|
|
52
|
+
.replace(/"/g, "'")
|
|
53
|
+
.replace(/</g, "<")
|
|
54
|
+
.replace(/>/g, ">")
|
|
55
|
+
.slice(0, 100);
|
|
56
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GatewayLogger } from "./log.js";
|
|
2
|
+
import { type SessionStore } from "./session-store.js";
|
|
3
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
|
+
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
5
|
+
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
6
|
+
/** Constructor options for `Dispatcher`. */
|
|
7
|
+
export interface DispatcherOptions {
|
|
8
|
+
config: GatewayConfig;
|
|
9
|
+
channels: Map<string, ChannelAdapter>;
|
|
10
|
+
runtime: RuntimeFactory;
|
|
11
|
+
sessionStore: SessionStore;
|
|
12
|
+
log: GatewayLogger;
|
|
13
|
+
turnTimeoutMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Live reference to the Gateway's managed-route map. Dispatcher reads
|
|
16
|
+
* `values()` on every `resolveRoute` call so hot-add/remove take effect
|
|
17
|
+
* without restart.
|
|
18
|
+
*/
|
|
19
|
+
managedRoutes?: Map<string, GatewayRoute>;
|
|
20
|
+
/**
|
|
21
|
+
* Optional hook producing a `systemContext` string for each turn. Result is
|
|
22
|
+
* forwarded to the runtime as `RuntimeRunOptions.systemContext`. Errors are
|
|
23
|
+
* swallowed and logged — they never abort the turn.
|
|
24
|
+
*/
|
|
25
|
+
buildSystemContext?: SystemContextBuilder;
|
|
26
|
+
/**
|
|
27
|
+
* Optional side-effect hook invoked after ack, before the turn runs.
|
|
28
|
+
* Intended for bookkeeping (e.g. activity tracking). Errors are logged
|
|
29
|
+
* and suppressed so the turn is never cancelled by observer failure.
|
|
30
|
+
*/
|
|
31
|
+
onInbound?: InboundObserver;
|
|
32
|
+
/**
|
|
33
|
+
* Optional composer that wraps `message.text` with channel-specific
|
|
34
|
+
* metadata (sender label, room header, reply hints…) before it is handed
|
|
35
|
+
* to the runtime. Skipped if it throws — the raw trimmed text is used as
|
|
36
|
+
* a fallback so a buggy composer cannot drop turns.
|
|
37
|
+
*/
|
|
38
|
+
composeUserTurn?: UserTurnBuilder;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
42
|
+
* turn per message, respecting queue mode, trust level, streaming, and
|
|
43
|
+
* session persistence rules from the plan (§7/§9/§10/§11/§12/§13).
|
|
44
|
+
*
|
|
45
|
+
* Deliberate deviation from daemon: this core does NOT wrap inbound text in
|
|
46
|
+
* BotCord-style XML envelopes for untrusted content. The channel adapter is
|
|
47
|
+
* responsible for any channel-specific sanitization; the dispatcher passes
|
|
48
|
+
* `message.text` through to the runtime as-is (plan §15).
|
|
49
|
+
*/
|
|
50
|
+
export declare class Dispatcher {
|
|
51
|
+
private readonly config;
|
|
52
|
+
private readonly channels;
|
|
53
|
+
private readonly runtimeFactory;
|
|
54
|
+
private readonly sessionStore;
|
|
55
|
+
private readonly log;
|
|
56
|
+
private readonly turnTimeoutMs;
|
|
57
|
+
private readonly buildSystemContext?;
|
|
58
|
+
private readonly onInbound?;
|
|
59
|
+
private readonly composeUserTurn?;
|
|
60
|
+
private readonly managedRoutes?;
|
|
61
|
+
private readonly queues;
|
|
62
|
+
constructor(opts: DispatcherOptions);
|
|
63
|
+
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
64
|
+
handle(envelope: GatewayInboundEnvelope): Promise<void>;
|
|
65
|
+
/** Snapshot of currently running turns keyed by queue key. */
|
|
66
|
+
turns(): Record<string, TurnStatusSnapshot>;
|
|
67
|
+
private safeAck;
|
|
68
|
+
private getQueue;
|
|
69
|
+
private runCancelPrevious;
|
|
70
|
+
private runSerial;
|
|
71
|
+
private runTurn;
|
|
72
|
+
private sendReply;
|
|
73
|
+
}
|