mcp-coordinator 0.2.0 → 0.3.0
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/README.md +846 -835
- package/dashboard/Dockerfile +19 -19
- package/dashboard/public/index.html +1178 -1178
- package/dist/cli/dashboard.js +9 -5
- package/dist/cli/server/start.js +24 -1
- package/dist/cli/server/status.js +16 -23
- package/dist/src/agent-activity.js +6 -6
- package/dist/src/agent-registry.js +6 -6
- package/dist/src/announce-workflow.d.ts +52 -0
- package/dist/src/announce-workflow.js +91 -0
- package/dist/src/consultation.d.ts +14 -0
- package/dist/src/consultation.js +110 -45
- package/dist/src/database.js +126 -126
- package/dist/src/dependency-map.js +3 -3
- package/dist/src/file-tracker.js +8 -8
- package/dist/src/http/handle-rest.d.ts +23 -0
- package/dist/src/http/handle-rest.js +374 -0
- package/dist/src/http/utils.d.ts +15 -0
- package/dist/src/http/utils.js +39 -0
- package/dist/src/introspection.js +1 -1
- package/dist/src/mqtt-bridge.d.ts +2 -0
- package/dist/src/mqtt-bridge.js +2 -0
- package/dist/src/mqtt-broker.d.ts +16 -0
- package/dist/src/mqtt-broker.js +16 -1
- package/dist/src/path-guard.d.ts +14 -0
- package/dist/src/path-guard.js +44 -0
- package/dist/src/reset-guard.d.ts +16 -0
- package/dist/src/reset-guard.js +24 -0
- package/dist/src/serve-http.d.ts +31 -1
- package/dist/src/serve-http.js +154 -445
- package/dist/src/server-setup.js +15 -364
- package/dist/src/tools/agents-tools.d.ts +8 -0
- package/dist/src/tools/agents-tools.js +46 -0
- package/dist/src/tools/consultation-tools.d.ts +21 -0
- package/dist/src/tools/consultation-tools.js +170 -0
- package/dist/src/tools/dependencies-tools.d.ts +8 -0
- package/dist/src/tools/dependencies-tools.js +27 -0
- package/dist/src/tools/files-tools.d.ts +8 -0
- package/dist/src/tools/files-tools.js +28 -0
- package/dist/src/tools/mqtt-tools.d.ts +9 -0
- package/dist/src/tools/mqtt-tools.js +33 -0
- package/dist/src/tools/status-tools.d.ts +8 -0
- package/dist/src/tools/status-tools.js +63 -0
- package/package.json +81 -80
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { getDb } from "../database.js";
|
|
2
|
+
import { runCommonAnnounceFlow } from "../announce-workflow.js";
|
|
3
|
+
import { canResetDb } from "../reset-guard.js";
|
|
4
|
+
import { parseBody, json } from "./utils.js";
|
|
5
|
+
export async function handleRest(req, res, ctx) {
|
|
6
|
+
const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
|
|
7
|
+
const url = req.url || "";
|
|
8
|
+
const body = await parseBody(req);
|
|
9
|
+
const agentId = body.agent_id;
|
|
10
|
+
// Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
|
|
11
|
+
// to keep the info log focused on coordination events (announce, claim, resolve, etc).
|
|
12
|
+
const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
|
|
13
|
+
// Note: /api/quota/refresh is NOT in the poll list — it's a manual user
|
|
14
|
+
// action and deserves an info-level log for auditability.
|
|
15
|
+
if (isPoll) {
|
|
16
|
+
httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
20
|
+
}
|
|
21
|
+
const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
|
|
22
|
+
if (url === "/api/register") {
|
|
23
|
+
const { agent_id, name, modules } = body;
|
|
24
|
+
const agent = registry.register(agent_id, name, modules || []);
|
|
25
|
+
sseEmitter.emit("agent_online", { agent_id, name, modules });
|
|
26
|
+
json(res, agent);
|
|
27
|
+
}
|
|
28
|
+
else if (url === "/api/session-start") {
|
|
29
|
+
const online = registry.listOnline();
|
|
30
|
+
const openThreads = consultation.listThreads({ status: "open" });
|
|
31
|
+
const hotFiles = fileTracker.getHotFiles(30);
|
|
32
|
+
const briefing = [
|
|
33
|
+
`Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
|
|
34
|
+
`Consultations ouvertes: ${openThreads.length}`,
|
|
35
|
+
`Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
|
|
36
|
+
].join("\n");
|
|
37
|
+
json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
|
|
38
|
+
}
|
|
39
|
+
else if (url === "/api/session-stop") {
|
|
40
|
+
const { agent_id } = body;
|
|
41
|
+
registry.setOffline(agent_id);
|
|
42
|
+
activityTracker.reportOffline(agent_id);
|
|
43
|
+
consultation.handleAgentDeparture(agent_id);
|
|
44
|
+
sseEmitter.emit("agent_offline", { agent_id });
|
|
45
|
+
json(res, { ok: true });
|
|
46
|
+
}
|
|
47
|
+
else if (url === "/api/check-conflict") {
|
|
48
|
+
const { file, agent_id } = body;
|
|
49
|
+
const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
|
|
50
|
+
const warnings = [];
|
|
51
|
+
if (conflict.conflict) {
|
|
52
|
+
warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
json(res, { conflict: conflict.conflict, warnings });
|
|
55
|
+
}
|
|
56
|
+
else if (url === "/api/log-file") {
|
|
57
|
+
const { session_id, agent_id, agent_name, tool_name, file } = body;
|
|
58
|
+
fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
59
|
+
activityTracker.reportFileActivity(agent_id, file);
|
|
60
|
+
sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
|
|
61
|
+
json(res, { ok: true });
|
|
62
|
+
}
|
|
63
|
+
else if (url === "/api/announce") {
|
|
64
|
+
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
|
|
65
|
+
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
66
|
+
const agentInfo = registry.get(agent_id);
|
|
67
|
+
// S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
|
|
68
|
+
// impact_scored + introspection SSE, plan-quality downgrade event). Same
|
|
69
|
+
// function used by the MCP announce_work tool path.
|
|
70
|
+
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
71
|
+
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
72
|
+
});
|
|
73
|
+
// REST-specific thread_opened SSE shape (different field set than MCP — kept
|
|
74
|
+
// divergent because consumers may depend on this exact contract).
|
|
75
|
+
sseEmitter.emit("thread_opened", {
|
|
76
|
+
thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
|
|
77
|
+
target_modules, target_files, expected_respondents: respondents,
|
|
78
|
+
conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
|
|
79
|
+
created_at: updated.created_at,
|
|
80
|
+
mode: planQuality.mode,
|
|
81
|
+
plan: plan || null,
|
|
82
|
+
plan_quality: planQuality,
|
|
83
|
+
});
|
|
84
|
+
json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
|
|
85
|
+
}
|
|
86
|
+
else if (url === "/api/post-to-thread") {
|
|
87
|
+
const { thread_id, agent_id, agent_name, type, content } = body;
|
|
88
|
+
// Pre-check the thread so we can return actionable status codes instead
|
|
89
|
+
// of always-500 on any error. The client uses the status to decide
|
|
90
|
+
// whether to warn (unexpected) or silently skip (normal race).
|
|
91
|
+
const targetThread = consultation.getThread(thread_id);
|
|
92
|
+
if (!targetThread) {
|
|
93
|
+
json(res, { error: "thread_not_found", thread_id }, 404);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (targetThread.status === "cancelled") {
|
|
97
|
+
json(res, { error: "thread_cancelled", thread_id }, 410);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
|
|
101
|
+
const thread = consultation.getThread(thread_id);
|
|
102
|
+
sseEmitter.emit("message_posted", {
|
|
103
|
+
thread_id, agent_id, agent_name: agent_name || agent_id,
|
|
104
|
+
type, content, round: thread?.round || 1,
|
|
105
|
+
token_estimate: msg.token_estimate || 0,
|
|
106
|
+
});
|
|
107
|
+
json(res, msg);
|
|
108
|
+
}
|
|
109
|
+
else if (url === "/api/token-usage") {
|
|
110
|
+
// Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
|
|
111
|
+
// and reports can pinpoint where tokens are being burned.
|
|
112
|
+
const payload = body;
|
|
113
|
+
sseEmitter.emit("token_usage", payload);
|
|
114
|
+
json(res, { ok: true });
|
|
115
|
+
}
|
|
116
|
+
else if (url === "/api/unclaim-task") {
|
|
117
|
+
const { thread_id, agent_id } = body;
|
|
118
|
+
if (!thread_id || !agent_id) {
|
|
119
|
+
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const db = getDb();
|
|
123
|
+
// F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
|
|
124
|
+
// to "poisoned" so no agent claims it again — prevents the tight
|
|
125
|
+
// claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
|
|
126
|
+
// Only the claiming agent can unclaim to prevent cross-agent interference.
|
|
127
|
+
const POISON_THRESHOLD = 2;
|
|
128
|
+
const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, agent_id);
|
|
129
|
+
let poisoned = false;
|
|
130
|
+
if (result.changes === 1) {
|
|
131
|
+
const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
|
|
132
|
+
if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
|
|
133
|
+
db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
|
|
134
|
+
poisoned = true;
|
|
135
|
+
httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
json(res, { success: result.changes === 1, poisoned });
|
|
139
|
+
}
|
|
140
|
+
else if (url === "/api/claim-task") {
|
|
141
|
+
const { thread_id, agent_id } = body;
|
|
142
|
+
if (!thread_id || !agent_id) {
|
|
143
|
+
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const db = getDb();
|
|
147
|
+
// Only claim threads with status='open' — poisoned threads are filtered out
|
|
148
|
+
// automatically because the status filter excludes them.
|
|
149
|
+
// Directed-dispatch constraint: if assigned_to is set, only that specific
|
|
150
|
+
// agent can claim; NULL keeps the original open-pool semantics.
|
|
151
|
+
const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL AND status = 'open' AND (assigned_to IS NULL OR assigned_to = ?)").run(agent_id, new Date().toISOString(), thread_id, agent_id);
|
|
152
|
+
if (result.changes === 1) {
|
|
153
|
+
mqttBridge.publishTaskClaimed(thread_id, agent_id);
|
|
154
|
+
sseEmitter.emit("task_claimed", { thread_id, agent_id });
|
|
155
|
+
json(res, { success: true });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const thread = consultation.getThread(thread_id);
|
|
159
|
+
// Surface the assigned_to in the 'why not' response so clients can
|
|
160
|
+
// distinguish "already claimed by X" from "reserved for Y".
|
|
161
|
+
json(res, {
|
|
162
|
+
success: false,
|
|
163
|
+
claimed_by: thread?.claimed_by || null,
|
|
164
|
+
assigned_to: thread?.assigned_to || null,
|
|
165
|
+
status: thread?.status,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (url === "/api/propose-resolution") {
|
|
170
|
+
const { thread_id, agent_id, summary } = body;
|
|
171
|
+
const agentInfo = registry.get(agent_id);
|
|
172
|
+
consultation.proposeResolution(thread_id, agent_id, summary);
|
|
173
|
+
sseEmitter.emit("resolution_proposed", {
|
|
174
|
+
thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
|
|
175
|
+
});
|
|
176
|
+
json(res, consultation.getThread(thread_id));
|
|
177
|
+
mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
|
|
178
|
+
}
|
|
179
|
+
else if (url === "/api/approve-resolution") {
|
|
180
|
+
const { thread_id, agent_id } = body;
|
|
181
|
+
const agentInfo = registry.get(agent_id);
|
|
182
|
+
consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
|
|
183
|
+
const t = consultation.getThread(thread_id);
|
|
184
|
+
json(res, t);
|
|
185
|
+
}
|
|
186
|
+
else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
|
|
187
|
+
const threadId = url.split("/")[3];
|
|
188
|
+
const thread = consultation.getThreadWithMessages(threadId);
|
|
189
|
+
if (!thread) {
|
|
190
|
+
json(res, { error: "not found" }, 404);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
json(res, {
|
|
194
|
+
status: thread.thread.status,
|
|
195
|
+
messages: thread.messages,
|
|
196
|
+
resolution_summary: thread.thread.resolution_summary,
|
|
197
|
+
expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (url === "/api/threads-active") {
|
|
202
|
+
const open = consultation.listThreads({ status: "open" });
|
|
203
|
+
const resolving = consultation.listThreads({ status: "resolving" });
|
|
204
|
+
json(res, [...open, ...resolving]);
|
|
205
|
+
}
|
|
206
|
+
else if (url === "/api/hot-files") {
|
|
207
|
+
const { since_minutes } = body;
|
|
208
|
+
json(res, fileTracker.getHotFiles(since_minutes || 30));
|
|
209
|
+
}
|
|
210
|
+
else if (url === "/api/quota") {
|
|
211
|
+
// Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
|
|
212
|
+
// Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
|
|
213
|
+
// 503 as "quota unknown = proceed" (fail-open) per the project decision.
|
|
214
|
+
const info = await quotaCache.get();
|
|
215
|
+
if (!info) {
|
|
216
|
+
const status = quotaCache.snapshot();
|
|
217
|
+
json(res, {
|
|
218
|
+
error: "quota unavailable",
|
|
219
|
+
reason: status.lastError,
|
|
220
|
+
cooldown_until: status.cooldownUntil,
|
|
221
|
+
}, 503);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
json(res, {
|
|
225
|
+
five_hour: info.fiveHour,
|
|
226
|
+
seven_day: info.sevenDay,
|
|
227
|
+
seven_day_sonnet: info.sevenDaySonnet,
|
|
228
|
+
fetched_at: info.fetchedAt,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (url === "/api/quota/refresh") {
|
|
233
|
+
// Force-refresh the cache, bypassing the TTL. Used by the dashboard's
|
|
234
|
+
// manual refresh button. The underlying quotaCache.refresh() is single-
|
|
235
|
+
// flight-deduped, so mashing the button doesn't stack parallel fetches.
|
|
236
|
+
// The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
|
|
237
|
+
// dashboard receives the update through the normal channel too — this
|
|
238
|
+
// endpoint only exists for "give me the answer now" semantics.
|
|
239
|
+
const info = await quotaCache.refresh();
|
|
240
|
+
if (!info) {
|
|
241
|
+
const status = quotaCache.snapshot();
|
|
242
|
+
json(res, {
|
|
243
|
+
error: "quota unavailable",
|
|
244
|
+
reason: status.lastError,
|
|
245
|
+
cooldown_until: status.cooldownUntil,
|
|
246
|
+
}, 503);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
json(res, {
|
|
250
|
+
five_hour: info.fiveHour,
|
|
251
|
+
seven_day: info.sevenDay,
|
|
252
|
+
seven_day_sonnet: info.sevenDaySonnet,
|
|
253
|
+
fetched_at: info.fetchedAt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (url === "/api/introspection-response") {
|
|
258
|
+
const { introspection_id, concerned, reason } = body;
|
|
259
|
+
const intro = introspection.respond(introspection_id, concerned, reason);
|
|
260
|
+
// If concerned, add to thread's expected_respondents
|
|
261
|
+
if (concerned && intro) {
|
|
262
|
+
const db = getDb();
|
|
263
|
+
const thread = consultation.getThread(intro.thread_id);
|
|
264
|
+
if (thread && (thread.status === "open" || thread.status === "resolving")) {
|
|
265
|
+
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
266
|
+
if (!respondents.includes(intro.agent_id)) {
|
|
267
|
+
respondents.push(intro.agent_id);
|
|
268
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
269
|
+
.run(JSON.stringify(respondents), thread.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const agentInfo = registry.get(intro?.agent_id || "");
|
|
274
|
+
sseEmitter.emit("introspection_completed", {
|
|
275
|
+
introspection_id, thread_id: intro?.thread_id,
|
|
276
|
+
agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
|
|
277
|
+
concerned, reason,
|
|
278
|
+
});
|
|
279
|
+
json(res, intro);
|
|
280
|
+
}
|
|
281
|
+
else if (url?.startsWith("/api/pending-introspections")) {
|
|
282
|
+
const urlObj = new URL(url, "http://localhost");
|
|
283
|
+
const agent_id = urlObj.searchParams.get("agent_id") || "";
|
|
284
|
+
const pending = introspection.getPending(agent_id);
|
|
285
|
+
json(res, pending);
|
|
286
|
+
}
|
|
287
|
+
else if (url === "/api/run-config") {
|
|
288
|
+
if (req.method === "POST") {
|
|
289
|
+
setRunConfig(body);
|
|
290
|
+
sseEmitter.emit("run_config", getRunConfig());
|
|
291
|
+
json(res, { ok: true });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
json(res, getRunConfig() || { active: false });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (url === "/api/reset") {
|
|
298
|
+
// B4 fix: gate destructive reset when AUTH is disabled.
|
|
299
|
+
// When AUTH_ENABLED=true, ADMIN_ONLY_ROUTES already enforced upstream
|
|
300
|
+
// by authenticateRequest (see auth.ts). This guard covers the AUTH off case.
|
|
301
|
+
if (!canResetDb(process.env, authEnabled)) {
|
|
302
|
+
json(res, {
|
|
303
|
+
error: "Forbidden: /api/reset requires NODE_ENV=test, COORDINATOR_ALLOW_RESET=true, or COORDINATOR_AUTH_ENABLED with admin token",
|
|
304
|
+
}, 403);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Reset all tables for clean test run (disable FK checks to avoid ordering issues)
|
|
308
|
+
const db = getDb();
|
|
309
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
310
|
+
db.exec("DELETE FROM introspections");
|
|
311
|
+
db.exec("DELETE FROM events");
|
|
312
|
+
db.exec("DELETE FROM thread_messages");
|
|
313
|
+
db.exec("DELETE FROM threads");
|
|
314
|
+
db.exec("DELETE FROM action_summaries");
|
|
315
|
+
db.exec("DELETE FROM file_activity");
|
|
316
|
+
db.exec("DELETE FROM agent_activity_status");
|
|
317
|
+
db.exec("DELETE FROM dependency_map");
|
|
318
|
+
db.exec("DELETE FROM agents");
|
|
319
|
+
db.exec("DELETE FROM revoked_agents");
|
|
320
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
321
|
+
setRunConfig(null);
|
|
322
|
+
json(res, { ok: true });
|
|
323
|
+
}
|
|
324
|
+
else if (url === "/api/check-interrupt") {
|
|
325
|
+
const { agent_id } = body;
|
|
326
|
+
// Check for threads where this agent is an expected respondent and hasn't posted yet.
|
|
327
|
+
// Covers both open threads (waiting for initial response) and resolving threads
|
|
328
|
+
// (waiting for approval/contest of a proposed resolution).
|
|
329
|
+
const pendingThreads = [
|
|
330
|
+
...consultation.listThreads({ status: "open" }),
|
|
331
|
+
...consultation.listThreads({ status: "resolving" }),
|
|
332
|
+
].filter((t) => {
|
|
333
|
+
const respondents = JSON.parse(t.expected_respondents || "[]");
|
|
334
|
+
return respondents.includes(agent_id);
|
|
335
|
+
});
|
|
336
|
+
if (pendingThreads.length > 0) {
|
|
337
|
+
const details = pendingThreads.map((t) => ({
|
|
338
|
+
thread_id: t.id,
|
|
339
|
+
subject: t.subject,
|
|
340
|
+
initiator_id: t.initiator_id,
|
|
341
|
+
status: t.status,
|
|
342
|
+
target_files: JSON.parse(t.target_files || "[]"),
|
|
343
|
+
}));
|
|
344
|
+
json(res, { interrupt: true, threads: details });
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
json(res, { interrupt: false });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (url?.startsWith("/api/agent-status/")) {
|
|
351
|
+
const aid = url.split("/")[3];
|
|
352
|
+
const agent = registry.get(aid);
|
|
353
|
+
if (!agent) {
|
|
354
|
+
json(res, { registered: false, status: "unknown" });
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
|
|
358
|
+
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (url === "/api/status") {
|
|
362
|
+
const online = registry.listOnline();
|
|
363
|
+
const openThreads = consultation.listThreads({ status: "open" });
|
|
364
|
+
json(res, {
|
|
365
|
+
online: online.length,
|
|
366
|
+
open_threads: openThreads.length,
|
|
367
|
+
hot_files: fileTracker.getHotFiles(30).length,
|
|
368
|
+
mqtt: services.mqttBridge.isConnected(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
json(res, { error: "not found" }, 404);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
/**
|
|
3
|
+
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
|
+
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
7
|
+
export declare function json(res: ServerResponse, data: unknown, status?: number): void;
|
|
8
|
+
/**
|
|
9
|
+
* Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
|
|
10
|
+
* ourselves (to read the `exp` claim before returning it to the client). Real
|
|
11
|
+
* verification of inbound tokens happens in `authenticateRequest` via
|
|
12
|
+
* jose.jwtVerify().
|
|
13
|
+
*/
|
|
14
|
+
export declare function decodeJwtPayload(token: string): Record<string, unknown>;
|
|
15
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
|
+
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
5
|
+
*/
|
|
6
|
+
export function parseBody(req) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let body = "";
|
|
9
|
+
req.on("data", (chunk) => (body += chunk.toString()));
|
|
10
|
+
req.on("end", () => {
|
|
11
|
+
try {
|
|
12
|
+
resolve(body ? JSON.parse(body) : {});
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
reject(new Error("Invalid JSON"));
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
req.on("error", reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function json(res, data, status = 200) {
|
|
22
|
+
res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
23
|
+
res.end(JSON.stringify(data));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
|
|
27
|
+
* ourselves (to read the `exp` claim before returning it to the client). Real
|
|
28
|
+
* verification of inbound tokens happens in `authenticateRequest` via
|
|
29
|
+
* jose.jwtVerify().
|
|
30
|
+
*/
|
|
31
|
+
export function decodeJwtPayload(token) {
|
|
32
|
+
const base64url = token.split(".")[1];
|
|
33
|
+
return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
|
|
34
|
+
}
|
|
35
|
+
export function safeEqual(a, b) {
|
|
36
|
+
if (a.length !== b.length)
|
|
37
|
+
return false;
|
|
38
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
39
|
+
}
|
|
@@ -4,7 +4,7 @@ export class IntrospectionManager {
|
|
|
4
4
|
create(params) {
|
|
5
5
|
const db = getDb();
|
|
6
6
|
const id = randomUUID();
|
|
7
|
-
db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
|
|
7
|
+
db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
|
|
8
8
|
VALUES (?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.score, JSON.stringify(params.reasons));
|
|
9
9
|
return this.get(id);
|
|
10
10
|
}
|
package/dist/src/mqtt-bridge.js
CHANGED
|
@@ -5,11 +5,27 @@ export interface EmbeddedMqttBroker {
|
|
|
5
5
|
wsPath: string | null;
|
|
6
6
|
close: () => Promise<void>;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* B3 fix: opt-in MQTT authentication. When provided, every CONNECT packet's
|
|
10
|
+
* password field is passed to authenticate(). Returning false rejects the
|
|
11
|
+
* client. When omitted (default), the broker accepts anonymous connections —
|
|
12
|
+
* preserving the existing behavior so essaim and other clients without auth
|
|
13
|
+
* keep working unchanged.
|
|
14
|
+
*
|
|
15
|
+
* The internal coordinator client (MqttBridge) bypasses this by passing an
|
|
16
|
+
* internal admin token when AUTH_ENABLED is true.
|
|
17
|
+
*/
|
|
18
|
+
export type MqttAuthVerifier = (username: string | undefined, password: Buffer | undefined) => Promise<boolean>;
|
|
8
19
|
export interface EmbeddedMqttOptions {
|
|
9
20
|
tcpPort?: number;
|
|
10
21
|
httpServer?: HttpServer;
|
|
11
22
|
wsPath?: string;
|
|
12
23
|
logger: Logger;
|
|
24
|
+
/**
|
|
25
|
+
* Per-CONNECT auth verifier. Omit to allow anonymous (default — backwards
|
|
26
|
+
* compatible with essaim and any client not using auth).
|
|
27
|
+
*/
|
|
28
|
+
authenticate?: MqttAuthVerifier;
|
|
13
29
|
}
|
|
14
30
|
/**
|
|
15
31
|
* Start an embedded MQTT broker (aedes) exposed via TCP, WebSocket, or both.
|
package/dist/src/mqtt-broker.js
CHANGED
|
@@ -38,8 +38,23 @@ function wsToDuplex(ws) {
|
|
|
38
38
|
* fully ready, which causes client connect timeouts in compiled binaries.
|
|
39
39
|
*/
|
|
40
40
|
export async function startEmbeddedMqttBroker(opts) {
|
|
41
|
-
const { tcpPort, httpServer, wsPath = "/mqtt", logger } = opts;
|
|
41
|
+
const { tcpPort, httpServer, wsPath = "/mqtt", logger, authenticate } = opts;
|
|
42
42
|
const broker = await Aedes.createBroker();
|
|
43
|
+
if (authenticate) {
|
|
44
|
+
// B3 fix: when AUTH_ENABLED, every CONNECT must present a valid token.
|
|
45
|
+
broker.authenticate =
|
|
46
|
+
(client, username, password, cb) => {
|
|
47
|
+
Promise.resolve(authenticate(username, password)).then((ok) => {
|
|
48
|
+
if (!ok)
|
|
49
|
+
logger.warn({ client_id: client?.id, username }, "MQTT auth rejected");
|
|
50
|
+
cb(null, ok);
|
|
51
|
+
}, (err) => {
|
|
52
|
+
logger.warn({ client_id: client?.id, err: err.message }, "MQTT auth error");
|
|
53
|
+
cb(null, false);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
logger.info("MQTT auth enabled (token in CONNECT password)");
|
|
57
|
+
}
|
|
43
58
|
broker.on("client", (client) => {
|
|
44
59
|
logger.debug({ client_id: client?.id }, "MQTT client connected");
|
|
45
60
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a request URL into a safe filesystem path within a known root.
|
|
3
|
+
*
|
|
4
|
+
* Defends against path traversal: a request like `/dashboard/../../etc/passwd`
|
|
5
|
+
* would otherwise escape the dashboard directory because `path.join` does not
|
|
6
|
+
* validate that the result stays under the root.
|
|
7
|
+
*
|
|
8
|
+
* Returns the resolved absolute path on success, or `null` if the path would
|
|
9
|
+
* escape the root, contains a null byte, or is otherwise invalid.
|
|
10
|
+
*
|
|
11
|
+
* `urlPath` should already have the route prefix stripped (e.g. for
|
|
12
|
+
* `/dashboard/app.js` pass `"app.js"`).
|
|
13
|
+
*/
|
|
14
|
+
export declare function safeJoinUnderRoot(root: string, urlPath: string): string | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a request URL into a safe filesystem path within a known root.
|
|
4
|
+
*
|
|
5
|
+
* Defends against path traversal: a request like `/dashboard/../../etc/passwd`
|
|
6
|
+
* would otherwise escape the dashboard directory because `path.join` does not
|
|
7
|
+
* validate that the result stays under the root.
|
|
8
|
+
*
|
|
9
|
+
* Returns the resolved absolute path on success, or `null` if the path would
|
|
10
|
+
* escape the root, contains a null byte, or is otherwise invalid.
|
|
11
|
+
*
|
|
12
|
+
* `urlPath` should already have the route prefix stripped (e.g. for
|
|
13
|
+
* `/dashboard/app.js` pass `"app.js"`).
|
|
14
|
+
*/
|
|
15
|
+
export function safeJoinUnderRoot(root, urlPath) {
|
|
16
|
+
// Reject null bytes (NUL injection that some libs/OS handle inconsistently).
|
|
17
|
+
if (urlPath.includes("\0"))
|
|
18
|
+
return null;
|
|
19
|
+
// Decode percent-encoding so "%2e%2e/foo" cannot bypass the literal ".." check.
|
|
20
|
+
// decodeURIComponent throws on malformed sequences — treat as invalid.
|
|
21
|
+
let decoded;
|
|
22
|
+
try {
|
|
23
|
+
decoded = decodeURIComponent(urlPath);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (decoded.includes("\0"))
|
|
29
|
+
return null;
|
|
30
|
+
// Strip leading slashes so absolute-path injection ("//etc/passwd") becomes relative.
|
|
31
|
+
const trimmed = decoded.replace(/^[/\\]+/, "");
|
|
32
|
+
// Empty after strip → caller decides what default file to serve.
|
|
33
|
+
if (trimmed === "")
|
|
34
|
+
return null;
|
|
35
|
+
const rootResolved = path.resolve(root);
|
|
36
|
+
const candidate = path.resolve(rootResolved, trimmed);
|
|
37
|
+
// The resolved candidate MUST be either equal to the root or live below it.
|
|
38
|
+
// Append separator before comparison to avoid the "/var/data" vs "/var/data-evil" trap.
|
|
39
|
+
const rootWithSep = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
|
|
40
|
+
if (candidate !== rootResolved && !candidate.startsWith(rootWithSep)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for the destructive `/api/reset` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `/api/reset` wipes every coordination table. When `COORDINATOR_AUTH_ENABLED`
|
|
5
|
+
* is on, the upstream auth middleware enforces admin-role tokens for this route
|
|
6
|
+
* (see `auth.ts` `ADMIN_ONLY_ROUTES`). When auth is OFF (the default), we still
|
|
7
|
+
* need to prevent accidental data loss in production.
|
|
8
|
+
*
|
|
9
|
+
* Allowed when ANY of:
|
|
10
|
+
* - NODE_ENV === "test" (vitest sets this automatically)
|
|
11
|
+
* - COORDINATOR_ALLOW_RESET=true (explicit opt-in for dev/CI scripts)
|
|
12
|
+
* - authEnabled === true (handled by the auth middleware upstream)
|
|
13
|
+
*
|
|
14
|
+
* Otherwise rejected.
|
|
15
|
+
*/
|
|
16
|
+
export declare function canResetDb(env: NodeJS.ProcessEnv, authEnabled: boolean): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for the destructive `/api/reset` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `/api/reset` wipes every coordination table. When `COORDINATOR_AUTH_ENABLED`
|
|
5
|
+
* is on, the upstream auth middleware enforces admin-role tokens for this route
|
|
6
|
+
* (see `auth.ts` `ADMIN_ONLY_ROUTES`). When auth is OFF (the default), we still
|
|
7
|
+
* need to prevent accidental data loss in production.
|
|
8
|
+
*
|
|
9
|
+
* Allowed when ANY of:
|
|
10
|
+
* - NODE_ENV === "test" (vitest sets this automatically)
|
|
11
|
+
* - COORDINATOR_ALLOW_RESET=true (explicit opt-in for dev/CI scripts)
|
|
12
|
+
* - authEnabled === true (handled by the auth middleware upstream)
|
|
13
|
+
*
|
|
14
|
+
* Otherwise rejected.
|
|
15
|
+
*/
|
|
16
|
+
export function canResetDb(env, authEnabled) {
|
|
17
|
+
if (authEnabled)
|
|
18
|
+
return true;
|
|
19
|
+
if (env.NODE_ENV === "test")
|
|
20
|
+
return true;
|
|
21
|
+
if (env.COORDINATOR_ALLOW_RESET === "true")
|
|
22
|
+
return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
package/dist/src/serve-http.d.ts
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
export interface ServerOptions {
|
|
2
2
|
port?: number;
|
|
3
3
|
dataDir?: string;
|
|
4
|
+
/**
|
|
5
|
+
* MQTT TCP listener port. Defaults to COORDINATOR_MQTT_TCP_PORT env or 1883.
|
|
6
|
+
* Pass an OS-ephemeral free port (see net.createServer().listen(0)) to run
|
|
7
|
+
* multiple coordinators in the same process without collision.
|
|
8
|
+
*/
|
|
9
|
+
mqttTcpPort?: number;
|
|
10
|
+
/**
|
|
11
|
+
* MQTT WebSocket path on the HTTP server. Defaults to COORDINATOR_MQTT_WS_PATH or "/mqtt".
|
|
12
|
+
*/
|
|
13
|
+
mqttWsPath?: string;
|
|
14
|
+
/**
|
|
15
|
+
* If false, do NOT register process-level SIGTERM/SIGINT handlers. Default
|
|
16
|
+
* true. Embedders that manage their own signals (essaim's orchestrator runs
|
|
17
|
+
* many in-process coordinators per session) should pass false and call
|
|
18
|
+
* `handle.stop()` from their own teardown.
|
|
19
|
+
*/
|
|
20
|
+
registerSignalHandlers?: boolean;
|
|
4
21
|
}
|
|
5
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Returned by startServer(). Lets callers shut down all owned resources
|
|
24
|
+
* (HTTP server, MQTT broker + bridge, SSE listeners, DB, quota timer) without
|
|
25
|
+
* waiting for process exit. Safe to call multiple times.
|
|
26
|
+
*
|
|
27
|
+
* Backward-compatible: previous callers used `await startServer({...})` and
|
|
28
|
+
* ignored the resolved value. They continue to work; the new return value is
|
|
29
|
+
* additive.
|
|
30
|
+
*/
|
|
31
|
+
export interface ServerHandle {
|
|
32
|
+
port: number;
|
|
33
|
+
stop: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function startServer(opts?: ServerOptions): Promise<ServerHandle>;
|