@sleep2agi/commhub-server 0.9.0-preview.20 → 0.9.0-preview.22
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 +1 -1
- package/bin/commhub.ts +6 -2
- package/package.json +1 -1
- package/src/api-host-supervisors-fallback.test.ts +15 -5
- package/src/avatar-validate.test.ts +113 -0
- package/src/avatar-validate.ts +69 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/db.ts +10 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2380
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/push.ts +87 -7
- package/src/response-charset.test.ts +130 -0
- package/src/server.ts +2642 -0
- package/src/tools.ts +115 -4
- package/src/uploads-http.test.ts +11 -8
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
|
|
4
|
-
import { pushEvent } from "./push.js";
|
|
4
|
+
import { pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
5
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
6
6
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
7
7
|
import {
|
|
@@ -47,8 +47,10 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
|
|
|
47
47
|
import {
|
|
48
48
|
ALLOWED_FLAGS,
|
|
49
49
|
SECURITY_SENSITIVE_FLAGS,
|
|
50
|
+
EDITABLE_CHANNELS,
|
|
50
51
|
computeApplyMode,
|
|
51
52
|
validatePatch,
|
|
53
|
+
narrowChannelsPatch,
|
|
52
54
|
isAllowedToChangeFlag,
|
|
53
55
|
} from "./config-apply-validate.js";
|
|
54
56
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
@@ -956,6 +958,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
956
958
|
if (target.state === "online") {
|
|
957
959
|
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
958
960
|
}
|
|
961
|
+
// #461 network observer summary — unconditional (task row exists
|
|
962
|
+
// even when the target is offline/queued), metadata only.
|
|
963
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority });
|
|
959
964
|
|
|
960
965
|
if (target.state === "offline") {
|
|
961
966
|
return {
|
|
@@ -1183,6 +1188,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1183
1188
|
|
|
1184
1189
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
1185
1190
|
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
|
|
1191
|
+
// #461 network observer summary — ids + routing only, no reply text.
|
|
1192
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
|
|
1186
1193
|
|
|
1187
1194
|
return {
|
|
1188
1195
|
content: [{
|
|
@@ -1265,6 +1272,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1265
1272
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
1266
1273
|
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
1267
1274
|
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
|
|
1275
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
|
|
1268
1276
|
return {
|
|
1269
1277
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
1270
1278
|
};
|
|
@@ -1417,6 +1425,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1417
1425
|
});
|
|
1418
1426
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
|
|
1419
1427
|
pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
|
|
1428
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
|
|
1420
1429
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
|
|
1421
1430
|
}
|
|
1422
1431
|
);
|
|
@@ -1565,6 +1574,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1565
1574
|
patch: z.object({
|
|
1566
1575
|
model: z.string().max(200).optional(),
|
|
1567
1576
|
flags: z.record(z.unknown()).optional(),
|
|
1577
|
+
// #260 P5 — channel enable/disable. Restart-tier field (agent-node
|
|
1578
|
+
// reads config.channels once at boot to fork per-channel workers,
|
|
1579
|
+
// so the swap takes effect via process restart). Not
|
|
1580
|
+
// SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
|
|
1581
|
+
// (network scope) only.
|
|
1582
|
+
//
|
|
1583
|
+
// Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
|
|
1584
|
+
// `z.record(z.unknown())`): trust nothing at the wire boundary,
|
|
1585
|
+
// narrow the same way `narrowChannelsPatch` narrows raw untrusted
|
|
1586
|
+
// JSON (typeof + allowlist + dedup + case-fold). A strict
|
|
1587
|
+
// `z.array(z.string())` would fail-fast on a single non-string
|
|
1588
|
+
// entry, but the wire contract wants junk silently dropped so a
|
|
1589
|
+
// dashboard fat-finger doesn't turn into a 400 the user has to
|
|
1590
|
+
// interpret. validatePatch then re-rejects if the caller bypassed
|
|
1591
|
+
// narrowing.
|
|
1592
|
+
channels: z.array(z.unknown()).max(16).optional(),
|
|
1568
1593
|
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1569
1594
|
network_id: z.string().max(200).optional(),
|
|
1570
1595
|
},
|
|
@@ -1582,6 +1607,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1582
1607
|
|
|
1583
1608
|
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1584
1609
|
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
1610
|
+
// Narrow untrusted `channels` at the boundary (typeof + allowlist +
|
|
1611
|
+
// dedup + case-fold), and distinguish two very different cases
|
|
1612
|
+
// that both narrow to `[]`:
|
|
1613
|
+
// (a) `patch.channels === []` (explicit "disable all editable
|
|
1614
|
+
// channels"). Downstream must proceed and write channels=[]
|
|
1615
|
+
// so the node's next restart forks no workers.
|
|
1616
|
+
// (b) `patch.channels === ["commhub"]` or similar — the caller
|
|
1617
|
+
// sent items but every single one was invalid (dashboard PR
|
|
1618
|
+
// #31 still ships commhub, and a typo like "telegarm" would
|
|
1619
|
+
// hit the same path). Downstream MUST NOT treat this as
|
|
1620
|
+
// (a) — silently converting to disable-all would nuke the
|
|
1621
|
+
// user's existing telegram/feishu workers.
|
|
1622
|
+
//
|
|
1623
|
+
// The reject error is `channels_all_invalid` so the dashboard
|
|
1624
|
+
// can surface the mismatch instead of showing a false success.
|
|
1625
|
+
let channels: string[] | undefined = undefined;
|
|
1626
|
+
if (patch.channels !== undefined) {
|
|
1627
|
+
if (!Array.isArray(patch.channels)) {
|
|
1628
|
+
// Zod already permits arrays only; belt+braces if it drifts.
|
|
1629
|
+
return {
|
|
1630
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
const narrowed = narrowChannelsPatch(patch.channels) ?? [];
|
|
1634
|
+
if (patch.channels.length === 0) {
|
|
1635
|
+
channels = []; // (a) explicit disable-all
|
|
1636
|
+
} else if (narrowed.length === 0) {
|
|
1637
|
+
// (b) every entry was invalid — refuse to write. Distinct
|
|
1638
|
+
// error so the dashboard can surface the failure.
|
|
1639
|
+
return {
|
|
1640
|
+
content: [{
|
|
1641
|
+
type: "text" as const,
|
|
1642
|
+
text: JSON.stringify({
|
|
1643
|
+
ok: false,
|
|
1644
|
+
error: "channels_all_invalid",
|
|
1645
|
+
requested: patch.channels,
|
|
1646
|
+
message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
|
|
1647
|
+
}),
|
|
1648
|
+
}],
|
|
1649
|
+
};
|
|
1650
|
+
} else {
|
|
1651
|
+
channels = narrowed;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1585
1654
|
|
|
1586
1655
|
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1587
1656
|
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
@@ -1608,7 +1677,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1608
1677
|
};
|
|
1609
1678
|
}
|
|
1610
1679
|
|
|
1611
|
-
const validationFail = validatePatch(model, flags);
|
|
1680
|
+
const validationFail = validatePatch(model, flags, channels);
|
|
1612
1681
|
if (validationFail) {
|
|
1613
1682
|
return {
|
|
1614
1683
|
content: [{
|
|
@@ -1686,8 +1755,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1686
1755
|
|
|
1687
1756
|
// Compute apply_mode + persist + push doorbell.
|
|
1688
1757
|
const updateId = `cu_${uuidv4()}`;
|
|
1689
|
-
const applyMode = computeApplyMode(model, flags);
|
|
1690
|
-
const patchJson = JSON.stringify({
|
|
1758
|
+
const applyMode = computeApplyMode(model, flags, channels);
|
|
1759
|
+
const patchJson = JSON.stringify({
|
|
1760
|
+
...(model !== undefined ? { model } : {}),
|
|
1761
|
+
flags,
|
|
1762
|
+
...(channels !== undefined ? { channels } : {}),
|
|
1763
|
+
});
|
|
1691
1764
|
const networkId = node.network_id || "default";
|
|
1692
1765
|
db.run(
|
|
1693
1766
|
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
|
|
@@ -3554,6 +3627,15 @@ export function finalizePendingMatchingUpdates(
|
|
|
3554
3627
|
|
|
3555
3628
|
const snapModel: string | null | undefined = snapshot?.model;
|
|
3556
3629
|
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
3630
|
+
// #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
|
|
3631
|
+
// the node currently has forked. agent-node's buildConfigSnapshot
|
|
3632
|
+
// always emits it (even as []), so the "absent field" case here means
|
|
3633
|
+
// an older pre-#260-P5 agent-node — for those, don't finalize a
|
|
3634
|
+
// channels-carrying patch (would false-positive on the OTHER fields).
|
|
3635
|
+
const snapChannelsPresent = Array.isArray(snapshot?.channels);
|
|
3636
|
+
const snapChannels: string[] = snapChannelsPresent
|
|
3637
|
+
? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
|
|
3638
|
+
: [];
|
|
3557
3639
|
|
|
3558
3640
|
const finalizedIds: string[] = [];
|
|
3559
3641
|
|
|
@@ -3578,6 +3660,35 @@ export function finalizePendingMatchingUpdates(
|
|
|
3578
3660
|
if (snapFlags[k] !== v) { matches = false; break; }
|
|
3579
3661
|
}
|
|
3580
3662
|
}
|
|
3663
|
+
// #260 P5 — channels field content-match. Without this the
|
|
3664
|
+
// node's very first startup report_status matches (patch.flags
|
|
3665
|
+
// is `{}` for a channels-only update, so the flags loop is
|
|
3666
|
+
// trivially satisfied) and hub prematurely marks the update
|
|
3667
|
+
// applied, deleting the pending row + bumping config_revision
|
|
3668
|
+
// BEFORE the doorbell reaches the child. Codex catch on PR #411.
|
|
3669
|
+
//
|
|
3670
|
+
// Set-equality is enough because the patch side comes from
|
|
3671
|
+
// narrowChannelsPatch → already deduped + case-folded + in
|
|
3672
|
+
// EDITABLE_CHANNELS iteration order; the snapshot side comes
|
|
3673
|
+
// from buildConfigSnapshot which mirrors the same shape.
|
|
3674
|
+
if (matches && Array.isArray(patch.channels)) {
|
|
3675
|
+
if (!snapChannelsPresent) {
|
|
3676
|
+
// Pre-#260-P5 agent-node — no channels field in snapshot,
|
|
3677
|
+
// so we can't prove the apply landed. Skip finalize; hub's
|
|
3678
|
+
// F-B reaper still supersedes this eventually.
|
|
3679
|
+
matches = false;
|
|
3680
|
+
} else {
|
|
3681
|
+
const wanted = new Set<string>(patch.channels);
|
|
3682
|
+
const have = new Set<string>(snapChannels);
|
|
3683
|
+
if (wanted.size !== have.size) {
|
|
3684
|
+
matches = false;
|
|
3685
|
+
} else {
|
|
3686
|
+
for (const w of wanted) {
|
|
3687
|
+
if (!have.has(w)) { matches = false; break; }
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3581
3692
|
}
|
|
3582
3693
|
|
|
3583
3694
|
if (!matches) continue;
|
package/src/uploads-http.test.ts
CHANGED
|
@@ -14,8 +14,11 @@ import { register, login } from "./auth.js";
|
|
|
14
14
|
|
|
15
15
|
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
|
|
16
16
|
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #438 corrective: private bootServer({ port: 0 }) instance — the OS
|
|
18
|
+
// assigns the port and BASE is derived from the ACTUAL bound port, so
|
|
19
|
+
// this suite is immune to both the parent-side random-port TOCTOU and
|
|
20
|
+
// the aggregate module-cache no-op (import no longer boots anything).
|
|
21
|
+
let BASE = "";
|
|
19
22
|
|
|
20
23
|
let server: any;
|
|
21
24
|
let userToken = "";
|
|
@@ -27,7 +30,6 @@ beforeAll(async () => {
|
|
|
27
30
|
// we want to validate the production code path).
|
|
28
31
|
process.env.COMMHUB_DB = SERVER_DB;
|
|
29
32
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
30
|
-
process.env.PORT = String(PORT);
|
|
31
33
|
process.env.HOST = "127.0.0.1";
|
|
32
34
|
|
|
33
35
|
// Use a unique username + strong password each run so we don't trip
|
|
@@ -50,16 +52,17 @@ beforeAll(async () => {
|
|
|
50
52
|
}
|
|
51
53
|
expect(userToken).toBeTruthy();
|
|
52
54
|
|
|
53
|
-
//
|
|
54
|
-
// the port
|
|
55
|
-
await import("./
|
|
55
|
+
// Importing is side-effect-free now; boot a private instance and
|
|
56
|
+
// read the real port back.
|
|
57
|
+
const { bootServer } = await import("./server.js");
|
|
58
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
59
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
56
60
|
// Tiny settle so the listener is ready.
|
|
57
61
|
await new Promise((r) => setTimeout(r, 100));
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
afterAll(() => {
|
|
61
|
-
|
|
62
|
-
// time so we can't easily stop it — process exit handles that.
|
|
65
|
+
try { server?.stop?.(true); } catch {}
|
|
63
66
|
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
64
67
|
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
65
68
|
});
|