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.
Files changed (44) hide show
  1. package/README.md +846 -835
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1178 -1178
  4. package/dist/cli/dashboard.js +9 -5
  5. package/dist/cli/server/start.js +24 -1
  6. package/dist/cli/server/status.js +16 -23
  7. package/dist/src/agent-activity.js +6 -6
  8. package/dist/src/agent-registry.js +6 -6
  9. package/dist/src/announce-workflow.d.ts +52 -0
  10. package/dist/src/announce-workflow.js +91 -0
  11. package/dist/src/consultation.d.ts +14 -0
  12. package/dist/src/consultation.js +110 -45
  13. package/dist/src/database.js +126 -126
  14. package/dist/src/dependency-map.js +3 -3
  15. package/dist/src/file-tracker.js +8 -8
  16. package/dist/src/http/handle-rest.d.ts +23 -0
  17. package/dist/src/http/handle-rest.js +374 -0
  18. package/dist/src/http/utils.d.ts +15 -0
  19. package/dist/src/http/utils.js +39 -0
  20. package/dist/src/introspection.js +1 -1
  21. package/dist/src/mqtt-bridge.d.ts +2 -0
  22. package/dist/src/mqtt-bridge.js +2 -0
  23. package/dist/src/mqtt-broker.d.ts +16 -0
  24. package/dist/src/mqtt-broker.js +16 -1
  25. package/dist/src/path-guard.d.ts +14 -0
  26. package/dist/src/path-guard.js +44 -0
  27. package/dist/src/reset-guard.d.ts +16 -0
  28. package/dist/src/reset-guard.js +24 -0
  29. package/dist/src/serve-http.d.ts +31 -1
  30. package/dist/src/serve-http.js +154 -445
  31. package/dist/src/server-setup.js +15 -364
  32. package/dist/src/tools/agents-tools.d.ts +8 -0
  33. package/dist/src/tools/agents-tools.js +46 -0
  34. package/dist/src/tools/consultation-tools.d.ts +21 -0
  35. package/dist/src/tools/consultation-tools.js +170 -0
  36. package/dist/src/tools/dependencies-tools.d.ts +8 -0
  37. package/dist/src/tools/dependencies-tools.js +27 -0
  38. package/dist/src/tools/files-tools.d.ts +8 -0
  39. package/dist/src/tools/files-tools.js +28 -0
  40. package/dist/src/tools/mqtt-tools.d.ts +9 -0
  41. package/dist/src/tools/mqtt-tools.js +33 -0
  42. package/dist/src/tools/status-tools.d.ts +8 -0
  43. package/dist/src/tools/status-tools.js +63 -0
  44. package/package.json +81 -80
@@ -14,8 +14,10 @@ const __dirname = path.dirname(__filename);
14
14
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
15
  import { createServices, createMcpServer } from "./server-setup.js";
16
16
  import { createLogger } from "./logger.js";
17
- import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger } from "./auth.js";
18
- import { assessPlanQuality } from "./plan-quality.js";
17
+ import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
18
+ import { safeJoinUnderRoot } from "./path-guard.js";
19
+ import { handleRest as handleRestExt } from "./http/handle-rest.js";
20
+ import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
19
21
  import { getVersion } from "../cli/version.js";
20
22
  const VERSION = getVersion();
21
23
  import { startEmbeddedMqttBroker } from "./mqtt-broker.js";
@@ -48,439 +50,36 @@ let httpLog;
48
50
  let mcpLog;
49
51
  let authLog;
50
52
  let currentRunConfig = null;
51
- function parseBody(req) {
52
- return new Promise((resolve, reject) => {
53
- let body = "";
54
- req.on("data", (chunk) => (body += chunk.toString()));
55
- req.on("end", () => {
56
- try {
57
- resolve(body ? JSON.parse(body) : {});
58
- }
59
- catch {
60
- reject(new Error("Invalid JSON"));
61
- }
62
- });
63
- req.on("error", reject);
64
- });
65
- }
66
- function json(res, data, status = 200) {
67
- res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
68
- res.end(JSON.stringify(data));
69
- }
53
+ // S1: parseBody and json moved to ./http/utils.js (shared with handle-rest.ts).
54
+ // Re-bound to local names so the rest of this file (handleAuth, handleSse,
55
+ // startServer) can keep using `parseBody` / `json` without changes.
56
+ const parseBody = parseBodyShared;
57
+ const json = jsonShared;
70
58
  function decodeJwtPayload(token) {
59
+ // Used only on tokens we just minted ourselves (to read the `exp` claim
60
+ // before returning it to the client). Real verification of inbound tokens
61
+ // happens in `authenticateRequest` via jose.jwtVerify().
71
62
  const base64url = token.split(".")[1];
72
- const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
73
- return JSON.parse(atob(base64));
63
+ return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
74
64
  }
75
65
  function safeEqual(a, b) {
76
66
  if (a.length !== b.length)
77
67
  return false;
78
68
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
79
69
  }
70
+ // S1: handleRest extracted to ./http/handle-rest.ts. Thin wrapper here keeps
71
+ // startServer's call site stable while the 382-line REST router lives in its
72
+ // own module. currentRunConfig stays here as the single mutable owner; the
73
+ // extracted function reads/writes via getRunConfig/setRunConfig accessors.
80
74
  async function handleRest(req, res) {
81
- const url = req.url || "";
82
- const body = await parseBody(req);
83
- const agentId = body.agent_id;
84
- // Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
85
- // to keep the info log focused on coordination events (announce, claim, resolve, etc).
86
- const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
87
- // Note: /api/quota/refresh is NOT in the poll list — it's a manual user
88
- // action and deserves an info-level log for auditability.
89
- if (isPoll) {
90
- httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
91
- }
92
- else {
93
- httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
94
- }
95
- const { registry, activityTracker, consultation, fileTracker, impactScorer, introspection, sseEmitter, mqttBridge, quotaCache } = services;
96
- if (url === "/api/register") {
97
- const { agent_id, name, modules } = body;
98
- const agent = registry.register(agent_id, name, modules || []);
99
- sseEmitter.emit("agent_online", { agent_id, name, modules });
100
- json(res, agent);
101
- }
102
- else if (url === "/api/session-start") {
103
- const { agent_id, agent_name } = body;
104
- const online = registry.listOnline();
105
- const openThreads = consultation.listThreads({ status: "open" });
106
- const hotFiles = fileTracker.getHotFiles(30);
107
- const briefing = [
108
- `Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
109
- `Consultations ouvertes: ${openThreads.length}`,
110
- `Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
111
- ].join("\n");
112
- json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
113
- }
114
- else if (url === "/api/session-stop") {
115
- const { agent_id } = body;
116
- registry.setOffline(agent_id);
117
- activityTracker.reportOffline(agent_id);
118
- consultation.handleAgentDeparture(agent_id);
119
- sseEmitter.emit("agent_offline", { agent_id });
120
- json(res, { ok: true });
121
- }
122
- else if (url === "/api/check-conflict") {
123
- const { file, agent_id } = body;
124
- const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
125
- const warnings = [];
126
- if (conflict.conflict) {
127
- warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
128
- }
129
- json(res, { conflict: conflict.conflict, warnings });
130
- }
131
- else if (url === "/api/log-file") {
132
- const { session_id, agent_id, agent_name, tool_name, file } = body;
133
- fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
134
- activityTracker.reportFileActivity(agent_id, file);
135
- sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
136
- json(res, { ok: true });
137
- }
138
- else if (url === "/api/announce") {
139
- const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
140
- // Quality gate on plan
141
- const planQuality = assessPlanQuality(plan);
142
- const effectiveMode = planQuality.mode;
143
- const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
144
- const agentInfo = registry.get(agent_id);
145
- // Impact scoring: categorize all online agents
146
- const categorized = impactScorer.categorize({
147
- agent_id, target_modules, target_files, depends_on_files, exports_affected,
148
- });
149
- // Override expected_respondents with concerned agents from scorer
150
- {
151
- const db = (await import("./database.js")).getDb();
152
- const concernedIds = categorized.concerned.map(s => s.agent_id);
153
- db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
154
- .run(JSON.stringify(concernedIds), thread.id);
155
- // Only auto-resolve when truly alone — no other online agents.
156
- // If peers are online but not yet concerned (e.g. they haven't announced
157
- // yet), keep the thread open so a subsequent announce can still match
158
- // this work via Layer 0. Thread will timeout naturally if no one joins.
159
- const otherOnlineCount = registry.listOnline().filter((a) => a.id !== agent_id).length;
160
- const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
161
- if (shouldAutoResolve && thread.status === "open" && !keep_open) {
162
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
163
- .run(new Date().toISOString(), thread.id);
164
- consultation.emitResolution(thread.id, "auto_resolved");
165
- }
166
- }
167
- // Emit impact_scored SSE events for all agents
168
- for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
169
- sseEmitter.emit("impact_scored", {
170
- thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name,
171
- score: s.score, reasons: s.reasons, category: s.score >= 90 ? "concerned" : s.score >= 30 ? "gray_zone" : "pass",
172
- });
173
- }
174
- // Create introspection records and emit introspection_requested for gray_zone agents
175
- for (const s of categorized.gray_zone) {
176
- introspection.create({ thread_id: thread.id, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
177
- sseEmitter.emit("introspection_requested", {
178
- thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name, score: s.score, reasons: s.reasons,
179
- });
180
- }
181
- const updated = consultation.getThread(thread.id);
182
- const respondents = JSON.parse(updated.expected_respondents || "[]");
183
- // Emit downgrade event when plan is provided but quality is insufficient
184
- if (plan && effectiveMode === "discovery") {
185
- sseEmitter.emit("impact_scored", {
186
- thread_id: thread.id,
187
- agent_id: agent_id,
188
- agent_name: agentInfo?.name || agent_id,
189
- score: planQuality.score,
190
- reasons: [`plan downgraded: score ${planQuality.score}/3 — ${!planQuality.checks.mentions_files ? 'no files' : ''} ${!planQuality.checks.concrete_approach ? 'vague approach' : ''} ${!planQuality.checks.sufficient_detail ? 'too short' : ''}`.trim()],
191
- category: "plan_quality",
192
- });
193
- }
194
- sseEmitter.emit("thread_opened", {
195
- thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
196
- target_modules, target_files, expected_respondents: respondents,
197
- conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
198
- created_at: updated.created_at,
199
- mode: effectiveMode,
200
- plan: plan || null,
201
- plan_quality: planQuality,
202
- });
203
- json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
204
- }
205
- else if (url === "/api/post-to-thread") {
206
- const { thread_id, agent_id, agent_name, type, content } = body;
207
- // Pre-check the thread so we can return actionable status codes instead
208
- // of always-500 on any error. The client uses the status to decide
209
- // whether to warn (unexpected) or silently skip (normal race).
210
- const targetThread = consultation.getThread(thread_id);
211
- if (!targetThread) {
212
- json(res, { error: "thread_not_found", thread_id }, 404);
213
- return;
214
- }
215
- if (targetThread.status === "cancelled") {
216
- json(res, { error: "thread_cancelled", thread_id }, 410);
217
- return;
218
- }
219
- const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
220
- const thread = consultation.getThread(thread_id);
221
- sseEmitter.emit("message_posted", {
222
- thread_id, agent_id, agent_name: agent_name || agent_id,
223
- type, content, round: thread?.round || 1,
224
- token_estimate: msg.token_estimate || 0,
225
- });
226
- json(res, msg);
227
- }
228
- else if (url === "/api/token-usage") {
229
- // Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
230
- // and reports can pinpoint where tokens are being burned.
231
- const payload = body;
232
- sseEmitter.emit("token_usage", payload);
233
- json(res, { ok: true });
234
- }
235
- else if (url === "/api/unclaim-task") {
236
- const { thread_id, agent_id } = body;
237
- if (!thread_id || !agent_id) {
238
- json(res, { success: false, error: "thread_id and agent_id required" }, 400);
239
- return;
240
- }
241
- const db = (await import("./database.js")).getDb();
242
- // F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
243
- // to "poisoned" so no agent claims it again — prevents the tight
244
- // claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
245
- // Only the claiming agent can unclaim to prevent cross-agent interference.
246
- const POISON_THRESHOLD = 2;
247
- 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);
248
- let poisoned = false;
249
- if (result.changes === 1) {
250
- const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
251
- if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
252
- db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
253
- poisoned = true;
254
- httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
255
- }
256
- }
257
- json(res, { success: result.changes === 1, poisoned });
258
- }
259
- else if (url === "/api/claim-task") {
260
- const { thread_id, agent_id } = body;
261
- if (!thread_id || !agent_id) {
262
- json(res, { success: false, error: "thread_id and agent_id required" }, 400);
263
- return;
264
- }
265
- const db = (await import("./database.js")).getDb();
266
- // Only claim threads with status='open' — poisoned threads are filtered out
267
- // automatically because the status filter excludes them.
268
- // Directed-dispatch constraint: if assigned_to is set, only that specific
269
- // agent can claim; NULL keeps the original open-pool semantics.
270
- 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);
271
- if (result.changes === 1) {
272
- mqttBridge.publishTaskClaimed(thread_id, agent_id);
273
- sseEmitter.emit("task_claimed", { thread_id, agent_id });
274
- json(res, { success: true });
275
- }
276
- else {
277
- const thread = consultation.getThread(thread_id);
278
- // Surface the assigned_to in the 'why not' response so clients can
279
- // distinguish "already claimed by X" from "reserved for Y".
280
- json(res, {
281
- success: false,
282
- claimed_by: thread?.claimed_by || null,
283
- assigned_to: thread?.assigned_to || null,
284
- status: thread?.status,
285
- });
286
- }
287
- }
288
- else if (url === "/api/propose-resolution") {
289
- const { thread_id, agent_id, summary } = body;
290
- const agentInfo = registry.get(agent_id);
291
- consultation.proposeResolution(thread_id, agent_id, summary);
292
- sseEmitter.emit("resolution_proposed", {
293
- thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
294
- });
295
- json(res, consultation.getThread(thread_id));
296
- mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
297
- }
298
- else if (url === "/api/approve-resolution") {
299
- const { thread_id, agent_id } = body;
300
- const agentInfo = registry.get(agent_id);
301
- consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
302
- const t = consultation.getThread(thread_id);
303
- json(res, t);
304
- }
305
- else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
306
- const threadId = url.split("/")[3];
307
- const thread = consultation.getThreadWithMessages(threadId);
308
- if (!thread) {
309
- json(res, { error: "not found" }, 404);
310
- }
311
- else {
312
- json(res, {
313
- status: thread.thread.status,
314
- messages: thread.messages,
315
- resolution_summary: thread.thread.resolution_summary,
316
- expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
317
- });
318
- }
319
- }
320
- else if (url === "/api/threads-active") {
321
- const open = consultation.listThreads({ status: "open" });
322
- const resolving = consultation.listThreads({ status: "resolving" });
323
- json(res, [...open, ...resolving]);
324
- }
325
- else if (url === "/api/hot-files") {
326
- const { since_minutes } = body;
327
- json(res, fileTracker.getHotFiles(since_minutes || 30));
328
- }
329
- else if (url === "/api/quota") {
330
- // Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
331
- // Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
332
- // 503 as "quota unknown = proceed" (fail-open) per the project decision.
333
- const info = await quotaCache.get();
334
- if (!info) {
335
- const status = quotaCache.snapshot();
336
- json(res, {
337
- error: "quota unavailable",
338
- reason: status.lastError,
339
- cooldown_until: status.cooldownUntil,
340
- }, 503);
341
- }
342
- else {
343
- json(res, {
344
- five_hour: info.fiveHour,
345
- seven_day: info.sevenDay,
346
- seven_day_sonnet: info.sevenDaySonnet,
347
- fetched_at: info.fetchedAt,
348
- });
349
- }
350
- }
351
- else if (url === "/api/quota/refresh") {
352
- // Force-refresh the cache, bypassing the TTL. Used by the dashboard's
353
- // manual refresh button. The underlying quotaCache.refresh() is single-
354
- // flight-deduped, so mashing the button doesn't stack parallel fetches.
355
- // The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
356
- // dashboard receives the update through the normal channel too — this
357
- // endpoint only exists for "give me the answer now" semantics.
358
- const info = await quotaCache.refresh();
359
- if (!info) {
360
- const status = quotaCache.snapshot();
361
- json(res, {
362
- error: "quota unavailable",
363
- reason: status.lastError,
364
- cooldown_until: status.cooldownUntil,
365
- }, 503);
366
- }
367
- else {
368
- json(res, {
369
- five_hour: info.fiveHour,
370
- seven_day: info.sevenDay,
371
- seven_day_sonnet: info.sevenDaySonnet,
372
- fetched_at: info.fetchedAt,
373
- });
374
- }
375
- }
376
- else if (url === "/api/introspection-response") {
377
- const { introspection_id, concerned, reason } = body;
378
- const intro = introspection.respond(introspection_id, concerned, reason);
379
- // If concerned, add to thread's expected_respondents
380
- if (concerned && intro) {
381
- const db = (await import("./database.js")).getDb();
382
- const thread = consultation.getThread(intro.thread_id);
383
- if (thread && (thread.status === "open" || thread.status === "resolving")) {
384
- const respondents = JSON.parse(thread.expected_respondents || "[]");
385
- if (!respondents.includes(intro.agent_id)) {
386
- respondents.push(intro.agent_id);
387
- db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
388
- .run(JSON.stringify(respondents), thread.id);
389
- }
390
- }
391
- }
392
- const agentInfo = registry.get(intro?.agent_id || "");
393
- sseEmitter.emit("introspection_completed", {
394
- introspection_id, thread_id: intro?.thread_id,
395
- agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
396
- concerned, reason,
397
- });
398
- json(res, intro);
399
- }
400
- else if (url?.startsWith("/api/pending-introspections")) {
401
- const urlObj = new URL(url, "http://localhost");
402
- const agent_id = urlObj.searchParams.get("agent_id") || "";
403
- const pending = introspection.getPending(agent_id);
404
- json(res, pending);
405
- }
406
- else if (url === "/api/run-config") {
407
- if (req.method === "POST") {
408
- currentRunConfig = body;
409
- sseEmitter.emit("run_config", currentRunConfig);
410
- json(res, { ok: true });
411
- }
412
- else {
413
- json(res, currentRunConfig || { active: false });
414
- }
415
- }
416
- else if (url === "/api/reset") {
417
- // Reset all tables for clean test run (disable FK checks to avoid ordering issues)
418
- const db = (await import("./database.js")).getDb();
419
- db.exec("PRAGMA foreign_keys = OFF");
420
- db.exec("DELETE FROM introspections");
421
- db.exec("DELETE FROM events");
422
- db.exec("DELETE FROM thread_messages");
423
- db.exec("DELETE FROM threads");
424
- db.exec("DELETE FROM action_summaries");
425
- db.exec("DELETE FROM file_activity");
426
- db.exec("DELETE FROM agent_activity_status");
427
- db.exec("DELETE FROM dependency_map");
428
- db.exec("DELETE FROM agents");
429
- db.exec("DELETE FROM revoked_agents");
430
- db.exec("PRAGMA foreign_keys = ON");
431
- currentRunConfig = null;
432
- json(res, { ok: true });
433
- }
434
- else if (url === "/api/check-interrupt") {
435
- const { agent_id } = body;
436
- // Check for threads where this agent is an expected respondent and hasn't posted yet.
437
- // Covers both open threads (waiting for initial response) and resolving threads
438
- // (waiting for approval/contest of a proposed resolution).
439
- const pendingThreads = [
440
- ...consultation.listThreads({ status: "open" }),
441
- ...consultation.listThreads({ status: "resolving" }),
442
- ].filter((t) => {
443
- const respondents = JSON.parse(t.expected_respondents || "[]");
444
- return respondents.includes(agent_id);
445
- });
446
- if (pendingThreads.length > 0) {
447
- const details = pendingThreads.map((t) => ({
448
- thread_id: t.id,
449
- subject: t.subject,
450
- initiator_id: t.initiator_id,
451
- status: t.status,
452
- target_files: JSON.parse(t.target_files || "[]"),
453
- }));
454
- json(res, { interrupt: true, threads: details });
455
- }
456
- else {
457
- json(res, { interrupt: false });
458
- }
459
- }
460
- else if (url?.startsWith("/api/agent-status/")) {
461
- const agentId = url.split("/")[3];
462
- const agent = registry.get(agentId);
463
- if (!agent) {
464
- json(res, { registered: false, status: "unknown" });
465
- }
466
- else {
467
- const activity = activityTracker.getActivity(agentId, { idleAfterMinutes: 5 });
468
- json(res, { registered: true, status: agent.status, activity: activity.activity_status });
469
- }
470
- }
471
- else if (url === "/api/status") {
472
- const online = registry.listOnline();
473
- const openThreads = consultation.listThreads({ status: "open" });
474
- json(res, {
475
- online: online.length,
476
- open_threads: openThreads.length,
477
- hot_files: fileTracker.getHotFiles(30).length,
478
- mqtt: services.mqttBridge.isConnected(),
479
- });
480
- }
481
- else {
482
- json(res, { error: "not found" }, 404);
483
- }
75
+ const ctx = {
76
+ services,
77
+ httpLog,
78
+ authEnabled: AUTH_ENABLED,
79
+ getRunConfig: () => currentRunConfig,
80
+ setRunConfig: (cfg) => { currentRunConfig = cfg; },
81
+ };
82
+ return handleRestExt(req, res, ctx);
484
83
  }
485
84
  async function handleAuth(req, res) {
486
85
  const url = req.url || "";
@@ -592,6 +191,9 @@ function handleSse(req, res) {
592
191
  export async function startServer(opts) {
593
192
  const port = opts?.port ?? PORT;
594
193
  const dataDir = opts?.dataDir ?? DATA_DIR;
194
+ // Resolve MQTT ports per-call so tests/embedders can override module-load env values.
195
+ const mqttTcpPort = opts?.mqttTcpPort ?? MQTT_TCP_PORT;
196
+ const mqttWsPath = opts?.mqttWsPath ?? MQTT_WS_PATH;
595
197
  services = createServices({ dataDir });
596
198
  const log = services.logger;
597
199
  httpLog = log.child({ component: "http" });
@@ -638,10 +240,19 @@ export async function startServer(opts) {
638
240
  json(res, { error: "dashboard not available" }, 404);
639
241
  return;
640
242
  }
641
- const filePath = url === "/dashboard" || url === "/dashboard/"
642
- ? path.join(dashboardDir, "index.html")
643
- : path.join(dashboardDir, url.replace("/dashboard/", ""));
644
- if (existsSync(filePath)) {
243
+ // B5 fix: defend against path traversal. safeJoinUnderRoot decodes the
244
+ // URL, strips leading slashes, resolves the path, and verifies the
245
+ // result stays under dashboardDir. Returns null on traversal attempts.
246
+ let filePath;
247
+ if (url === "/dashboard" || url === "/dashboard/") {
248
+ filePath = path.join(dashboardDir, "index.html");
249
+ }
250
+ else {
251
+ // Strip query string before joining (browsers append ?v=...)
252
+ const urlPath = (url.split("?")[0] || "").replace("/dashboard/", "");
253
+ filePath = safeJoinUnderRoot(dashboardDir, urlPath);
254
+ }
255
+ if (filePath && existsSync(filePath)) {
645
256
  const ext = path.extname(filePath);
646
257
  const contentTypes = {
647
258
  ".html": "text/html",
@@ -739,30 +350,128 @@ export async function startServer(opts) {
739
350
  // Start the embedded MQTT broker (TCP + WebSocket on HTTP upgrade).
740
351
  // Awaiting ensures the TCP listener is fully bound before we connect our
741
352
  // own client or tell users the coordinator is ready.
742
- await startEmbeddedMqttBroker({
743
- tcpPort: MQTT_TCP_PORT,
353
+ // B3 fix: when AUTH_ENABLED, gate every MQTT CONNECT by JWT in the password
354
+ // field. Anonymous connections are rejected. Default off (essaim and any
355
+ // client without auth keep working unchanged).
356
+ const mqttAuth = AUTH_ENABLED
357
+ ? async (_username, password) => {
358
+ if (!password)
359
+ return false;
360
+ try {
361
+ await verifyToken(password.toString("utf-8"));
362
+ return true;
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
368
+ : undefined;
369
+ const broker = await startEmbeddedMqttBroker({
370
+ tcpPort: mqttTcpPort,
744
371
  httpServer,
745
- wsPath: MQTT_WS_PATH,
372
+ wsPath: mqttWsPath,
746
373
  logger: log.child({ component: "mqtt-broker" }),
374
+ authenticate: mqttAuth,
375
+ });
376
+ // B3: when AUTH_ENABLED, the internal coordinator client must authenticate
377
+ // too. Mint a short-lived admin token for the bridge.
378
+ const internalToken = AUTH_ENABLED ? await createToken("coordinator-internal", "admin", "1h") : undefined;
379
+ await services.mqttBridge.connect({
380
+ url: `mqtt://127.0.0.1:${mqttTcpPort}`,
381
+ username: AUTH_ENABLED ? "coordinator-internal" : undefined,
382
+ password: internalToken,
747
383
  });
748
- // Connect the coordinator's own MQTT client to the embedded broker BEFORE
749
- // the HTTP server accepts requests — agents shouldn't see a half-ready coordinator.
750
- await services.mqttBridge.connect({ url: `mqtt://127.0.0.1:${MQTT_TCP_PORT}` });
751
384
  services.mqttBridge.onOffline((agentId) => {
752
385
  services.registry.setOffline(agentId);
753
386
  services.consultation.handleAgentDeparture(agentId);
754
387
  services.sseEmitter.emit("agent_offline", { agent_id: agentId });
755
388
  });
756
- httpServer.listen(port, () => {
757
- log.info({
758
- port,
759
- mcp: `POST http://localhost:${port}/mcp`,
760
- rest: `POST http://localhost:${port}/api/*`,
761
- sse: `GET http://localhost:${port}/api/events`,
762
- mqtt_tcp: `mqtt://127.0.0.1:${MQTT_TCP_PORT}`,
763
- mqtt_ws: `ws://localhost:${port}${MQTT_WS_PATH}`,
764
- }, "Coordinator v3 started");
389
+ // Wait for the HTTP server to be actually listening before resolving the
390
+ // returned handle. Otherwise callers (tests, essaim) may try to connect
391
+ // before the port is bound.
392
+ await new Promise((resolve, reject) => {
393
+ const onError = (err) => reject(err);
394
+ httpServer.once("error", onError);
395
+ httpServer.listen(port, () => {
396
+ httpServer.off("error", onError);
397
+ log.info({
398
+ port,
399
+ mcp: `POST http://localhost:${port}/mcp`,
400
+ rest: `POST http://localhost:${port}/api/*`,
401
+ sse: `GET http://localhost:${port}/api/events`,
402
+ mqtt_tcp: `mqtt://127.0.0.1:${mqttTcpPort}`,
403
+ mqtt_ws: `ws://localhost:${port}${mqttWsPath}`,
404
+ }, "Coordinator v3 started");
405
+ resolve();
406
+ });
765
407
  });
408
+ // B2 fix: start the consultation timeout sweeper.
409
+ // Reads no longer mutate state — this background tick handles timeouts.
410
+ services.consultation.startTimeoutSweeper();
411
+ // B6 fix: graceful shutdown.
412
+ // Cleanup sequence: stop accepting new HTTP connections → end MQTT bridge →
413
+ // close MQTT broker → stop quota background timer → close DB.
414
+ // Idempotent: stopped flag prevents double-cleanup if SIGTERM races with
415
+ // an explicit handle.stop() call.
416
+ let stopped = false;
417
+ const stop = async () => {
418
+ if (stopped)
419
+ return;
420
+ stopped = true;
421
+ log.info("Coordinator shutting down...");
422
+ try {
423
+ await new Promise((resolve) => httpServer.close(() => resolve()));
424
+ }
425
+ catch (err) {
426
+ log.warn({ err }, "Error closing HTTP server");
427
+ }
428
+ try {
429
+ await services.mqttBridge.disconnect();
430
+ }
431
+ catch (err) {
432
+ log.warn({ err }, "Error disconnecting MQTT bridge");
433
+ }
434
+ try {
435
+ await broker.close();
436
+ }
437
+ catch (err) {
438
+ log.warn({ err }, "Error closing MQTT broker");
439
+ }
440
+ try {
441
+ services.quotaCache.stopBackgroundTick();
442
+ }
443
+ catch (err) {
444
+ log.warn({ err }, "Error stopping quota timer");
445
+ }
446
+ try {
447
+ services.consultation.stopTimeoutSweeper();
448
+ }
449
+ catch (err) {
450
+ log.warn({ err }, "Error stopping timeout sweeper");
451
+ }
452
+ try {
453
+ const { closeDb } = await import("./database.js");
454
+ closeDb?.();
455
+ }
456
+ catch (err) {
457
+ log.warn({ err }, "Error closing database");
458
+ }
459
+ log.info("Coordinator shutdown complete");
460
+ };
461
+ // Register signal handlers (default true). Embedders can opt out via
462
+ // registerSignalHandlers: false to manage their own teardown.
463
+ if (opts?.registerSignalHandlers !== false) {
464
+ const onSignal = (signal) => {
465
+ log.info({ signal }, "Received shutdown signal");
466
+ stop().then(() => process.exit(0)).catch((err) => {
467
+ log.error({ err }, "Shutdown error, forcing exit");
468
+ process.exit(1);
469
+ });
470
+ };
471
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
472
+ process.once("SIGINT", () => onSignal("SIGINT"));
473
+ }
474
+ return { port, stop };
766
475
  }
767
476
  // Auto-start when run directly (not imported)
768
477
  const isMainModule = process.argv[1]?.endsWith("serve-http.ts") || process.argv[1]?.endsWith("serve-http.js");