@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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon ↔ Hub control-plane WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* One long-lived connection, carrying JSON {@link ControlFrame} messages.
|
|
5
|
+
* Independent from the agent data-plane WS: different auth (user access
|
|
6
|
+
* token vs agent JWT), different endpoint (`/daemon/ws`), different
|
|
7
|
+
* lifecycle (alive even when zero agents are bound).
|
|
8
|
+
*
|
|
9
|
+
* See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
|
|
10
|
+
*/
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import { buildDaemonWebSocketUrl, CONTROL_FRAME_TYPES, jcsCanonicalize, resolveHubControlPublicKey, verifyEd25519, } from "@botcord/protocol-core";
|
|
13
|
+
import { log as daemonLog } from "./log.js";
|
|
14
|
+
import { writeAuthExpiredFlag, } from "./user-auth.js";
|
|
15
|
+
/** Exponential backoff plan for transient disconnects. */
|
|
16
|
+
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
17
|
+
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
18
|
+
const REPLAY_DEDUPE_CAP = 256;
|
|
19
|
+
/**
|
|
20
|
+
* Build the canonical signing input for a control frame: RFC 8785 (JCS)
|
|
21
|
+
* canonicalization of `{id, type, params, ts}`. Per
|
|
22
|
+
* `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
|
|
23
|
+
* `jcs.canonicalize` over the same object before signing.
|
|
24
|
+
*
|
|
25
|
+
* Excludes `sig` by definition. `params` defaults to `{}` (empty object)
|
|
26
|
+
* to match the Hub-side default for paramless types like `ping`.
|
|
27
|
+
*/
|
|
28
|
+
export function controlSigningInput(frame) {
|
|
29
|
+
const obj = {
|
|
30
|
+
id: frame.id,
|
|
31
|
+
type: frame.type,
|
|
32
|
+
params: (frame.params ?? {}),
|
|
33
|
+
ts: typeof frame.ts === "number" ? frame.ts : 0,
|
|
34
|
+
};
|
|
35
|
+
return jcsCanonicalize(obj) ?? "{}";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Long-lived, self-healing WS connection that carries control frames
|
|
39
|
+
* between the Hub and the local daemon. Owns reconnect/backoff and
|
|
40
|
+
* dedupe; delegates frame semantics to a caller-supplied handler.
|
|
41
|
+
*/
|
|
42
|
+
export class ControlChannel {
|
|
43
|
+
auth;
|
|
44
|
+
handle;
|
|
45
|
+
path;
|
|
46
|
+
label;
|
|
47
|
+
hubPublicKey;
|
|
48
|
+
webSocketCtor;
|
|
49
|
+
backoff;
|
|
50
|
+
keepaliveMs;
|
|
51
|
+
ws = null;
|
|
52
|
+
stopRequested = false;
|
|
53
|
+
reconnectAttempts = 0;
|
|
54
|
+
reconnectTimer = null;
|
|
55
|
+
keepaliveTimer = null;
|
|
56
|
+
seenFrameIds = [];
|
|
57
|
+
connectInflight = null;
|
|
58
|
+
connected = false;
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
this.auth = opts.auth;
|
|
61
|
+
this.handle = opts.handle;
|
|
62
|
+
this.path = opts.path ?? "/daemon/ws";
|
|
63
|
+
// Prefer an explicit `label` from start-time; fall back to whatever
|
|
64
|
+
// was persisted on the user-auth record at login.
|
|
65
|
+
this.label = opts.label ?? opts.auth.current?.label;
|
|
66
|
+
this.hubPublicKey =
|
|
67
|
+
opts.hubPublicKey === undefined ? resolveHubControlPublicKey() : opts.hubPublicKey;
|
|
68
|
+
this.webSocketCtor = opts.webSocketCtor ?? WebSocket;
|
|
69
|
+
this.backoff = opts.backoffMs ?? RECONNECT_BACKOFF_MS;
|
|
70
|
+
this.keepaliveMs = opts.keepaliveIntervalMs ?? KEEPALIVE_INTERVAL_MS;
|
|
71
|
+
}
|
|
72
|
+
/** True once the initial WS handshake succeeded. Flipped back on close. */
|
|
73
|
+
get isConnected() {
|
|
74
|
+
return this.connected;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Open the WS. Resolves after the first `open` event — transient
|
|
78
|
+
* reconnects after that run in the background until `stop()` is
|
|
79
|
+
* called. Throws immediately if no user-auth record is loaded.
|
|
80
|
+
*/
|
|
81
|
+
async start() {
|
|
82
|
+
if (!this.auth.current) {
|
|
83
|
+
throw new Error("control-channel requires user-auth; run `botcord-daemon start`");
|
|
84
|
+
}
|
|
85
|
+
if (this.connectInflight)
|
|
86
|
+
return this.connectInflight;
|
|
87
|
+
this.stopRequested = false;
|
|
88
|
+
daemonLog.info("control-channel starting", {
|
|
89
|
+
userId: this.auth.current.userId,
|
|
90
|
+
hubUrl: this.auth.current.hubUrl,
|
|
91
|
+
path: this.path,
|
|
92
|
+
label: this.label ?? null,
|
|
93
|
+
hubKeyConfigured: !!this.hubPublicKey,
|
|
94
|
+
});
|
|
95
|
+
this.connectInflight = this.connect().catch((err) => {
|
|
96
|
+
// Initial connect failure surfaces to the caller; subsequent
|
|
97
|
+
// reconnects are handled opaquely inside onClose.
|
|
98
|
+
this.scheduleReconnect(err);
|
|
99
|
+
throw err;
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await this.connectInflight;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.connectInflight = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Close the WS and stop reconnecting. Idempotent. */
|
|
109
|
+
async stop() {
|
|
110
|
+
if (!this.stopRequested) {
|
|
111
|
+
daemonLog.info("control-channel stopping", { wasConnected: this.connected });
|
|
112
|
+
}
|
|
113
|
+
this.stopRequested = true;
|
|
114
|
+
if (this.reconnectTimer) {
|
|
115
|
+
clearTimeout(this.reconnectTimer);
|
|
116
|
+
this.reconnectTimer = null;
|
|
117
|
+
}
|
|
118
|
+
this.stopKeepalive();
|
|
119
|
+
const ws = this.ws;
|
|
120
|
+
this.ws = null;
|
|
121
|
+
this.connected = false;
|
|
122
|
+
if (ws) {
|
|
123
|
+
try {
|
|
124
|
+
ws.close(1000, "daemon stopping");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Actively send a frame (used for event reports like `agent_provisioned`). */
|
|
132
|
+
send(frame) {
|
|
133
|
+
const ws = this.ws;
|
|
134
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
135
|
+
daemonLog.debug("control-channel.send skipped (not open)", {
|
|
136
|
+
type: frame.type,
|
|
137
|
+
id: frame.id,
|
|
138
|
+
readyState: ws?.readyState ?? null,
|
|
139
|
+
});
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
ws.send(JSON.stringify(frame));
|
|
144
|
+
daemonLog.debug("control-channel.send", { type: frame.type, id: frame.id });
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
daemonLog.warn("control-channel.send failed", {
|
|
149
|
+
type: frame.type,
|
|
150
|
+
error: err instanceof Error ? err.message : String(err),
|
|
151
|
+
});
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async connect() {
|
|
156
|
+
const record = this.auth.current;
|
|
157
|
+
if (!record)
|
|
158
|
+
throw new Error("control-channel: no user-auth");
|
|
159
|
+
const accessToken = await this.auth.ensureAccessToken();
|
|
160
|
+
const url = buildDaemonWebSocketUrl(record.hubUrl, this.path, this.label ? { label: this.label } : undefined);
|
|
161
|
+
daemonLog.info("control-channel connecting", { url });
|
|
162
|
+
const ws = new this.webSocketCtor(url, {
|
|
163
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
164
|
+
});
|
|
165
|
+
this.ws = ws;
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const onOpen = () => {
|
|
168
|
+
ws.removeListener("error", onError);
|
|
169
|
+
this.connected = true;
|
|
170
|
+
this.reconnectAttempts = 0;
|
|
171
|
+
daemonLog.info("control-channel connected", { url });
|
|
172
|
+
this.startKeepalive();
|
|
173
|
+
resolve();
|
|
174
|
+
};
|
|
175
|
+
const onError = (err) => {
|
|
176
|
+
ws.removeListener("open", onOpen);
|
|
177
|
+
reject(err);
|
|
178
|
+
};
|
|
179
|
+
ws.once("open", onOpen);
|
|
180
|
+
ws.once("error", onError);
|
|
181
|
+
});
|
|
182
|
+
ws.on("message", (data) => this.onMessage(data));
|
|
183
|
+
ws.on("close", (code, reason) => this.onClose(code, reason));
|
|
184
|
+
ws.on("error", (err) => daemonLog.warn("control-channel error", {
|
|
185
|
+
error: err instanceof Error ? err.message : String(err),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
startKeepalive() {
|
|
189
|
+
this.stopKeepalive();
|
|
190
|
+
this.keepaliveTimer = setInterval(() => {
|
|
191
|
+
const ws = this.ws;
|
|
192
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
193
|
+
return;
|
|
194
|
+
try {
|
|
195
|
+
ws.ping();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// ignore — next failed send will trigger close
|
|
199
|
+
}
|
|
200
|
+
}, this.keepaliveMs);
|
|
201
|
+
}
|
|
202
|
+
stopKeepalive() {
|
|
203
|
+
if (this.keepaliveTimer) {
|
|
204
|
+
clearInterval(this.keepaliveTimer);
|
|
205
|
+
this.keepaliveTimer = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
onClose(code, reason) {
|
|
209
|
+
const reasonText = reason?.toString() || "";
|
|
210
|
+
this.connected = false;
|
|
211
|
+
this.stopKeepalive();
|
|
212
|
+
this.ws = null;
|
|
213
|
+
daemonLog.info("control-channel closed", { code, reason: reasonText });
|
|
214
|
+
// 4401 / 4403 = auth problem per the plan. Surface via the flag file
|
|
215
|
+
// and stop reconnecting; the daemon is now in a "needs re-login" state.
|
|
216
|
+
if (code === 4401 || code === 4403 || code === 1008) {
|
|
217
|
+
daemonLog.warn("control-channel auth rejected; marking auth expired", { code });
|
|
218
|
+
writeAuthExpiredFlag();
|
|
219
|
+
this.stopRequested = true;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this.stopRequested)
|
|
223
|
+
return;
|
|
224
|
+
this.scheduleReconnect();
|
|
225
|
+
}
|
|
226
|
+
scheduleReconnect(err) {
|
|
227
|
+
if (this.stopRequested)
|
|
228
|
+
return;
|
|
229
|
+
const attempt = this.reconnectAttempts;
|
|
230
|
+
this.reconnectAttempts = attempt + 1;
|
|
231
|
+
const delay = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
|
232
|
+
if (err) {
|
|
233
|
+
daemonLog.warn("control-channel reconnect scheduled", {
|
|
234
|
+
delayMs: delay,
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
daemonLog.info("control-channel reconnect scheduled", { delayMs: delay });
|
|
240
|
+
}
|
|
241
|
+
this.reconnectTimer = setTimeout(() => {
|
|
242
|
+
this.reconnectTimer = null;
|
|
243
|
+
if (this.stopRequested)
|
|
244
|
+
return;
|
|
245
|
+
this.connect().catch((err) => this.scheduleReconnect(err));
|
|
246
|
+
}, delay);
|
|
247
|
+
}
|
|
248
|
+
async onMessage(data) {
|
|
249
|
+
let frame;
|
|
250
|
+
try {
|
|
251
|
+
frame = JSON.parse(data.toString());
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
daemonLog.warn("control-channel: non-JSON frame", {
|
|
255
|
+
error: err instanceof Error ? err.message : String(err),
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!frame || typeof frame.id !== "string" || typeof frame.type !== "string") {
|
|
260
|
+
daemonLog.warn("control-channel: malformed frame", { frame });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Replay-window check: Hub-signed frames carry `ts`; absent on pong
|
|
264
|
+
// responses, so skip the check when not present.
|
|
265
|
+
if (typeof frame.ts === "number") {
|
|
266
|
+
const skewMs = Math.abs(Date.now() - frame.ts);
|
|
267
|
+
if (skewMs > 5 * 60 * 1000) {
|
|
268
|
+
daemonLog.warn("control-channel: rejecting frame with stale ts", {
|
|
269
|
+
type: frame.type,
|
|
270
|
+
id: frame.id,
|
|
271
|
+
skewMs,
|
|
272
|
+
});
|
|
273
|
+
this.sendAck({
|
|
274
|
+
id: frame.id,
|
|
275
|
+
ok: false,
|
|
276
|
+
error: { code: "stale_ts", message: `timestamp skew ${skewMs}ms exceeds window` },
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Hub→daemon signature check. Plan §8.3 mandates rejection of unsigned
|
|
282
|
+
// frames once a Hub key is configured. When no key is available (P1
|
|
283
|
+
// dev / placeholder constant), we log a warning per frame and accept,
|
|
284
|
+
// so the daemon can still bring up the control plane against a Hub
|
|
285
|
+
// that hasn't published its key yet.
|
|
286
|
+
if (this.hubPublicKey) {
|
|
287
|
+
if (typeof frame.sig !== "string" || frame.sig.length === 0) {
|
|
288
|
+
daemonLog.warn("control-channel: rejecting unsigned frame", {
|
|
289
|
+
type: frame.type,
|
|
290
|
+
id: frame.id,
|
|
291
|
+
});
|
|
292
|
+
this.sendAck({
|
|
293
|
+
id: frame.id,
|
|
294
|
+
ok: false,
|
|
295
|
+
error: { code: "unsigned", message: "hub signature required" },
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!verifyEd25519(this.hubPublicKey, controlSigningInput(frame), frame.sig)) {
|
|
300
|
+
daemonLog.warn("control-channel: rejecting frame with bad signature", {
|
|
301
|
+
type: frame.type,
|
|
302
|
+
id: frame.id,
|
|
303
|
+
});
|
|
304
|
+
this.sendAck({
|
|
305
|
+
id: frame.id,
|
|
306
|
+
ok: false,
|
|
307
|
+
error: { code: "bad_signature", message: "hub signature did not verify" },
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (typeof frame.sig === "string") {
|
|
313
|
+
// Key not configured yet; skip verification but warn loudly.
|
|
314
|
+
daemonLog.warn("control-channel: skipping signature verification (no Hub public key configured)", { type: frame.type, id: frame.id });
|
|
315
|
+
}
|
|
316
|
+
// Idempotent dedupe: replay the cached result would require storing
|
|
317
|
+
// past results — for P0 we just ack the dup with a no-op ok. The
|
|
318
|
+
// provisioner itself is the authoritative dedupe boundary for
|
|
319
|
+
// stateful operations.
|
|
320
|
+
if (this.seenFrameIds.includes(frame.id)) {
|
|
321
|
+
daemonLog.debug("control-channel: duplicate frame, acking as no-op", {
|
|
322
|
+
type: frame.type,
|
|
323
|
+
id: frame.id,
|
|
324
|
+
});
|
|
325
|
+
this.sendAck({ id: frame.id, ok: true, result: { duplicate: true } });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.seenFrameIds.push(frame.id);
|
|
329
|
+
if (this.seenFrameIds.length > REPLAY_DEDUPE_CAP) {
|
|
330
|
+
this.seenFrameIds.splice(0, this.seenFrameIds.length - REPLAY_DEDUPE_CAP);
|
|
331
|
+
}
|
|
332
|
+
daemonLog.debug("control-channel frame received", {
|
|
333
|
+
type: frame.type,
|
|
334
|
+
id: frame.id,
|
|
335
|
+
});
|
|
336
|
+
// Plan §6.3 — instance-level revoke: write the expired flag, ack, and
|
|
337
|
+
// tear down the control plane. Agent gateway stays up so existing
|
|
338
|
+
// agent tokens keep working until the operator re-authorizes.
|
|
339
|
+
if (frame.type === CONTROL_FRAME_TYPES.REVOKE) {
|
|
340
|
+
writeAuthExpiredFlag();
|
|
341
|
+
const reason = frame.params && typeof frame.params.reason === "string"
|
|
342
|
+
? frame.params.reason
|
|
343
|
+
: "revoked_by_hub";
|
|
344
|
+
daemonLog.warn("control-channel: instance revoked by hub", { reason });
|
|
345
|
+
this.sendAck({ id: frame.id, ok: true, result: { acknowledged: true } });
|
|
346
|
+
void this.stop();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const result = await this.handle(frame);
|
|
351
|
+
const ack = result
|
|
352
|
+
? { id: frame.id, ...result }
|
|
353
|
+
: { id: frame.id, ok: true };
|
|
354
|
+
daemonLog.debug("control-channel handler done", {
|
|
355
|
+
type: frame.type,
|
|
356
|
+
id: frame.id,
|
|
357
|
+
ok: ack.ok !== false,
|
|
358
|
+
});
|
|
359
|
+
this.sendAck(ack);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
363
|
+
daemonLog.error("control-channel handler failed", {
|
|
364
|
+
type: frame.type,
|
|
365
|
+
error: message,
|
|
366
|
+
});
|
|
367
|
+
this.sendAck({
|
|
368
|
+
id: frame.id,
|
|
369
|
+
ok: false,
|
|
370
|
+
error: { code: "handler_error", message },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
sendAck(ack) {
|
|
375
|
+
const ws = this.ws;
|
|
376
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
377
|
+
return;
|
|
378
|
+
try {
|
|
379
|
+
ws.send(JSON.stringify(ack));
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
daemonLog.warn("control-channel.ack failed", {
|
|
383
|
+
id: ack.id,
|
|
384
|
+
error: err instanceof Error ? err.message : String(err),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-room digest — a short block listing other rooms the agent is
|
|
3
|
+
* actively talking in, so a single turn isn't blind to parallel
|
|
4
|
+
* conversations.
|
|
5
|
+
*
|
|
6
|
+
* Unlike the plugin version (which reads OpenClaw's per-session message
|
|
7
|
+
* history via `runtime.subagent.getSessionMessages()`), the daemon doesn't
|
|
8
|
+
* have access to the underlying CLI's transcript. We synthesize the digest
|
|
9
|
+
* from the ActivityTracker's last-inbound preview instead — lower fidelity,
|
|
10
|
+
* but works uniformly across Claude Code / Codex / Gemini.
|
|
11
|
+
*/
|
|
12
|
+
import type { ActivityTracker } from "./activity-tracker.js";
|
|
13
|
+
export interface DigestOptions {
|
|
14
|
+
tracker: ActivityTracker;
|
|
15
|
+
agentId: string;
|
|
16
|
+
currentRoomId: string;
|
|
17
|
+
currentTopic?: string | null;
|
|
18
|
+
/** Time horizon for "active". Default 2 hours. */
|
|
19
|
+
windowMs?: number;
|
|
20
|
+
/** Cap on how many rooms appear in the digest. Default 5. */
|
|
21
|
+
maxEntries?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildCrossRoomDigest(opts: DigestOptions): string | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DEFAULT_MAX_ENTRIES = 5;
|
|
2
|
+
const DEFAULT_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
export function buildCrossRoomDigest(opts) {
|
|
4
|
+
const { tracker, agentId, currentRoomId, currentTopic = null, windowMs = DEFAULT_WINDOW_MS, maxEntries = DEFAULT_MAX_ENTRIES, } = opts;
|
|
5
|
+
const excludeKey = tracker.keyFor(agentId, currentRoomId, currentTopic ?? null);
|
|
6
|
+
const entries = tracker.listActive({ agentId, windowMs, excludeKey });
|
|
7
|
+
if (entries.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
const slice = entries.slice(0, maxEntries);
|
|
10
|
+
const total = entries.length + 1; // +1 for the current turn's room
|
|
11
|
+
const lines = [
|
|
12
|
+
"[BotCord Cross-Room Awareness]",
|
|
13
|
+
`You are currently active in ${total} BotCord sessions. Recent activity from other rooms:`,
|
|
14
|
+
];
|
|
15
|
+
for (const e of slice) {
|
|
16
|
+
lines.push(formatEntry(e));
|
|
17
|
+
}
|
|
18
|
+
return lines.join("\n");
|
|
19
|
+
}
|
|
20
|
+
function formatEntry(e) {
|
|
21
|
+
const ago = formatTimeAgo(e.lastActivityAt);
|
|
22
|
+
const senderLabel = describeSender(e);
|
|
23
|
+
const roomLabel = e.roomName ? `${e.roomName} (${e.roomId})` : e.roomId;
|
|
24
|
+
const topicLabel = e.topic ? ` / topic ${e.topic}` : "";
|
|
25
|
+
const preview = e.lastInboundPreview.trim();
|
|
26
|
+
if (!preview) {
|
|
27
|
+
return `- ${roomLabel}${topicLabel} — last activity ${ago}, no preview`;
|
|
28
|
+
}
|
|
29
|
+
return [
|
|
30
|
+
`- ${roomLabel}${topicLabel} — last ${ago}`,
|
|
31
|
+
` ${senderLabel}: ${preview}`,
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
function describeSender(e) {
|
|
35
|
+
switch (e.lastSenderKind) {
|
|
36
|
+
case "human":
|
|
37
|
+
return `human ${e.lastSender}`;
|
|
38
|
+
case "owner":
|
|
39
|
+
return `owner`;
|
|
40
|
+
default:
|
|
41
|
+
return `agent ${e.lastSender}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function formatTimeAgo(ts) {
|
|
45
|
+
const diffMs = Date.now() - ts;
|
|
46
|
+
if (diffMs < 0)
|
|
47
|
+
return "just now";
|
|
48
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
49
|
+
if (diffMin < 1)
|
|
50
|
+
return "just now";
|
|
51
|
+
if (diffMin < 60)
|
|
52
|
+
return `${diffMin}m ago`;
|
|
53
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
54
|
+
return `${diffHr}h ago`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { GatewayConfig, GatewayRoute } from "./gateway/index.js";
|
|
2
|
+
import type { DaemonConfig } from "./config.js";
|
|
3
|
+
/** Options accepted by {@link toGatewayConfig}. */
|
|
4
|
+
export interface ToGatewayConfigOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Explicit list of agent ids to bind channels to. When provided, overrides
|
|
7
|
+
* anything derivable from the daemon config itself. P1 passes discovered
|
|
8
|
+
* credentials in via this field so `toGatewayConfig` stays pure.
|
|
9
|
+
*/
|
|
10
|
+
agentIds?: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Per-agent runtime/cwd cached from credentials (see
|
|
13
|
+
* `docs/agent-runtime-property-plan.md`). When present for an agent id,
|
|
14
|
+
* `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
15
|
+
* turns to its runtime. Explicit `cfg.routes` entries still win because
|
|
16
|
+
* synthesized routes are appended after them.
|
|
17
|
+
*/
|
|
18
|
+
agentRuntimes?: Record<string, {
|
|
19
|
+
runtime?: string;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Historical channel id used when the daemon bound a single agent. Kept as a
|
|
25
|
+
* named export for any downstream reader that still references it; no new
|
|
26
|
+
* code paths in the daemon emit this id — channels are now keyed by agentId.
|
|
27
|
+
*
|
|
28
|
+
* @deprecated Channel ids are now the agentId itself.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
|
|
31
|
+
/** Channel `type` tag used by `createBotCordChannel`. */
|
|
32
|
+
export declare const BOTCORD_CHANNEL_TYPE = "botcord";
|
|
33
|
+
/**
|
|
34
|
+
* Convert the daemon's on-disk config into a gateway runtime config. Only
|
|
35
|
+
* used in-process at daemon boot; the daemon config file itself is the
|
|
36
|
+
* user-facing contract.
|
|
37
|
+
*
|
|
38
|
+
* When `opts.agentIds` is provided (discovery or explicit override), the
|
|
39
|
+
* mapper trusts that list verbatim. Otherwise it falls back to the legacy
|
|
40
|
+
* `resolveAgentIds(cfg)` path so callers that haven't been updated for P1
|
|
41
|
+
* keep working.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toGatewayConfig(cfg: DaemonConfig, opts?: ToGatewayConfigOptions): GatewayConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Build the daemon's managed per-agent routes. Emits exactly one route per
|
|
46
|
+
* `agentId`, keyed by `accountId`. `runtime` comes from the agent's cached
|
|
47
|
+
* metadata when present (credentials file), otherwise falls back to
|
|
48
|
+
* `defaultRoute.runtime`. `cwd` prefers the cached value but falls back to
|
|
49
|
+
* the agent's workspace directory (see plan §10) so every agent runs inside
|
|
50
|
+
* its own dedicated tree by default.
|
|
51
|
+
*
|
|
52
|
+
* Iteration order of `agentIds` is preserved in the resulting Map for test
|
|
53
|
+
* determinism; the gateway router does not depend on map order.
|
|
54
|
+
*
|
|
55
|
+
* Exported so `reload_config` and `provisionAgent` hot-add can share the
|
|
56
|
+
* same synthesis logic (plan §10.5).
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildManagedRoutes(agentIds: string[], agentRuntimes: Record<string, {
|
|
59
|
+
runtime?: string;
|
|
60
|
+
cwd?: string;
|
|
61
|
+
}>, defaultRoute: GatewayRoute): Map<string, GatewayRoute>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { resolveAgentIds } from "./config.js";
|
|
2
|
+
import { agentWorkspaceDir } from "./agent-workspace.js";
|
|
3
|
+
import { log as daemonLog } from "./log.js";
|
|
4
|
+
/**
|
|
5
|
+
* Historical channel id used when the daemon bound a single agent. Kept as a
|
|
6
|
+
* named export for any downstream reader that still references it; no new
|
|
7
|
+
* code paths in the daemon emit this id — channels are now keyed by agentId.
|
|
8
|
+
*
|
|
9
|
+
* @deprecated Channel ids are now the agentId itself.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
|
|
12
|
+
/** Channel `type` tag used by `createBotCordChannel`. */
|
|
13
|
+
export const BOTCORD_CHANNEL_TYPE = "botcord";
|
|
14
|
+
/**
|
|
15
|
+
* Map daemon's historical narrower TrustLevel ("owner" | "untrusted") onto
|
|
16
|
+
* gateway's ("owner" | "trusted" | "public"). Matches the adapter-level
|
|
17
|
+
* mapping in `adapters/runtimes.ts`: "untrusted" collapses to "public".
|
|
18
|
+
* Accepts `undefined` → `undefined` so callers can pass through.
|
|
19
|
+
*/
|
|
20
|
+
function mapTrustLevel(level) {
|
|
21
|
+
if (level === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
return level === "owner" ? "owner" : "public";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Translate a single daemon route rule into a gateway route. Gateway matches
|
|
27
|
+
* on the channel-agnostic fields; the daemon surface keeps the legacy
|
|
28
|
+
* `roomId`/`roomPrefix` aliases for backward compatibility. When both a
|
|
29
|
+
* legacy alias and its canonical field are present, the canonical field
|
|
30
|
+
* wins and a warning is logged.
|
|
31
|
+
*/
|
|
32
|
+
function mapRoute(r) {
|
|
33
|
+
const match = {};
|
|
34
|
+
if (r.match.channel)
|
|
35
|
+
match.channel = r.match.channel;
|
|
36
|
+
if (r.match.accountId)
|
|
37
|
+
match.accountId = r.match.accountId;
|
|
38
|
+
if (r.match.conversationId && r.match.roomId && r.match.conversationId !== r.match.roomId) {
|
|
39
|
+
daemonLog.warn("daemon.config.route.conflict", {
|
|
40
|
+
field: "conversationId",
|
|
41
|
+
roomId: r.match.roomId,
|
|
42
|
+
conversationId: r.match.conversationId,
|
|
43
|
+
resolution: "conversationId wins",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const conversationId = r.match.conversationId ?? r.match.roomId;
|
|
47
|
+
if (conversationId)
|
|
48
|
+
match.conversationId = conversationId;
|
|
49
|
+
if (r.match.conversationPrefix &&
|
|
50
|
+
r.match.roomPrefix &&
|
|
51
|
+
r.match.conversationPrefix !== r.match.roomPrefix) {
|
|
52
|
+
daemonLog.warn("daemon.config.route.conflict", {
|
|
53
|
+
field: "conversationPrefix",
|
|
54
|
+
roomPrefix: r.match.roomPrefix,
|
|
55
|
+
conversationPrefix: r.match.conversationPrefix,
|
|
56
|
+
resolution: "conversationPrefix wins",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const conversationPrefix = r.match.conversationPrefix ?? r.match.roomPrefix;
|
|
60
|
+
if (conversationPrefix)
|
|
61
|
+
match.conversationPrefix = conversationPrefix;
|
|
62
|
+
if (r.match.conversationKind)
|
|
63
|
+
match.conversationKind = r.match.conversationKind;
|
|
64
|
+
if (r.match.senderId)
|
|
65
|
+
match.senderId = r.match.senderId;
|
|
66
|
+
if (typeof r.match.mentioned === "boolean")
|
|
67
|
+
match.mentioned = r.match.mentioned;
|
|
68
|
+
const rawTrust = r.trustLevel;
|
|
69
|
+
return {
|
|
70
|
+
match,
|
|
71
|
+
runtime: r.adapter,
|
|
72
|
+
cwd: r.cwd,
|
|
73
|
+
extraArgs: r.extraArgs,
|
|
74
|
+
trustLevel: mapTrustLevel(rawTrust),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Convert the daemon's on-disk config into a gateway runtime config. Only
|
|
79
|
+
* used in-process at daemon boot; the daemon config file itself is the
|
|
80
|
+
* user-facing contract.
|
|
81
|
+
*
|
|
82
|
+
* When `opts.agentIds` is provided (discovery or explicit override), the
|
|
83
|
+
* mapper trusts that list verbatim. Otherwise it falls back to the legacy
|
|
84
|
+
* `resolveAgentIds(cfg)` path so callers that haven't been updated for P1
|
|
85
|
+
* keep working.
|
|
86
|
+
*/
|
|
87
|
+
export function toGatewayConfig(cfg, opts = {}) {
|
|
88
|
+
// One channel per configured agent. Channel id = agentId so session keys
|
|
89
|
+
// (`runtime:channel:accountId:kind:convId`) and activity records carry
|
|
90
|
+
// the agent identity end-to-end. Pre-multi-agent single-agent installs
|
|
91
|
+
// previously used the fixed id "botcord-main"; existing on-disk session
|
|
92
|
+
// entries keyed by that id are silently dropped on the first message
|
|
93
|
+
// after upgrade — a one-time reset, not a bug.
|
|
94
|
+
const agentIds = opts.agentIds ?? resolveAgentIds(cfg);
|
|
95
|
+
const channels = agentIds.map((agentId) => ({
|
|
96
|
+
id: agentId,
|
|
97
|
+
type: BOTCORD_CHANNEL_TYPE,
|
|
98
|
+
accountId: agentId,
|
|
99
|
+
agentId,
|
|
100
|
+
}));
|
|
101
|
+
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
102
|
+
// defensively so future config extensions can propagate without a shape bump.
|
|
103
|
+
const rawDefaultTrust = cfg.defaultRoute
|
|
104
|
+
.trustLevel;
|
|
105
|
+
const defaultRoute = {
|
|
106
|
+
runtime: cfg.defaultRoute.adapter,
|
|
107
|
+
cwd: cfg.defaultRoute.cwd,
|
|
108
|
+
extraArgs: cfg.defaultRoute.extraArgs,
|
|
109
|
+
// queueMode: omitted — dispatcher's kind-based default wins
|
|
110
|
+
// (direct → cancel-previous, group → serial).
|
|
111
|
+
trustLevel: mapTrustLevel(rawDefaultTrust),
|
|
112
|
+
};
|
|
113
|
+
const routes = (cfg.routes ?? []).map(mapRoute);
|
|
114
|
+
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
115
|
+
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
116
|
+
// `cfg.routes[]` stay untouched so an explicit operator override still
|
|
117
|
+
// wins on conflict — the gateway matches `routes[] → managedRoutes →
|
|
118
|
+
// defaultRoute` in that order.
|
|
119
|
+
const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
|
|
120
|
+
return {
|
|
121
|
+
channels,
|
|
122
|
+
defaultRoute,
|
|
123
|
+
routes,
|
|
124
|
+
managedRoutes: Array.from(managedMap.values()),
|
|
125
|
+
streamBlocks: cfg.streamBlocks,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Build the daemon's managed per-agent routes. Emits exactly one route per
|
|
130
|
+
* `agentId`, keyed by `accountId`. `runtime` comes from the agent's cached
|
|
131
|
+
* metadata when present (credentials file), otherwise falls back to
|
|
132
|
+
* `defaultRoute.runtime`. `cwd` prefers the cached value but falls back to
|
|
133
|
+
* the agent's workspace directory (see plan §10) so every agent runs inside
|
|
134
|
+
* its own dedicated tree by default.
|
|
135
|
+
*
|
|
136
|
+
* Iteration order of `agentIds` is preserved in the resulting Map for test
|
|
137
|
+
* determinism; the gateway router does not depend on map order.
|
|
138
|
+
*
|
|
139
|
+
* Exported so `reload_config` and `provisionAgent` hot-add can share the
|
|
140
|
+
* same synthesis logic (plan §10.5).
|
|
141
|
+
*/
|
|
142
|
+
export function buildManagedRoutes(agentIds, agentRuntimes, defaultRoute) {
|
|
143
|
+
const out = new Map();
|
|
144
|
+
for (const agentId of agentIds) {
|
|
145
|
+
const meta = agentRuntimes[agentId] ?? {};
|
|
146
|
+
out.set(agentId, {
|
|
147
|
+
match: { accountId: agentId },
|
|
148
|
+
runtime: meta.runtime ?? defaultRoute.runtime,
|
|
149
|
+
cwd: meta.cwd || agentWorkspaceDir(agentId),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|