@sleep2agi/commhub-server 0.9.0-preview.22 → 0.9.0-preview.23

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/src/server.ts CHANGED
@@ -6,6 +6,7 @@ import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
7
7
  import { assertNodeActive } from "./lifecycle-guard.js";
8
8
  import { validateAvatarUrl } from "./avatar-validate.js";
9
+ import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
9
10
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
10
11
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
11
12
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
@@ -213,6 +214,45 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
213
214
  return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
214
215
  }
215
216
 
217
+ // #495 — shared authorization gate for /api/files/:file_id downloads.
218
+ // Called from the GET handler and any future HEAD/Range/dashboard-proxy
219
+ // entry so the allow-list can't drift between methods (通信龙 clause 2:
220
+ // "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
221
+ //
222
+ // Returns true if the caller may read this entry; false otherwise. The
223
+ // caller emits 404 on false — never 403; 403 would leak that the
224
+ // file_id exists and enables enumeration.
225
+ //
226
+ // Allow-list (staged P0 hotfix; same-network non-owner is NOT included
227
+ // here — see follow-up #503):
228
+ // - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
229
+ // - admin utok_ → allow (mirrors requireAdminAuth).
230
+ // - Owner match: caller's resolved user_id === entry.owner_id.
231
+ // - null owner_id + DEV_OPEN → allow. DEV_OPEN uploads carry
232
+ // owner_id=null (authCtx=null in DEV_OPEN mode); rejecting them
233
+ // would break the intended local-dev anonymous flow. Encoded as
234
+ // an explicit env-gated branch so if DEV_OPEN semantics change,
235
+ // the code fails loudly instead of trusting a stale note.
236
+ // - Everything else → deny (404). New uploads in production always
237
+ // carry a truthy owner_id (requireAuth blocks the null-owner-
238
+ // producing path when DEV_OPEN=off and no legacy master token is
239
+ // configured), so denying null-owner in production closes the
240
+ // enumeration vector for legacy blobs without breaking new uploads.
241
+ export function authorizeFileDownload(req: Request, entry: { owner_id?: unknown }): boolean {
242
+ const token = requestToken(req);
243
+ const authCtx = resolveRequestAuth(req);
244
+ const resolved = token ? resolveToken(token) : null;
245
+ const isLegacyMasterCaller = !authCtx && isLegacyAuthToken(req);
246
+ const isAdminCaller = !!resolved && resolved.user.role === "admin";
247
+ const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
248
+ ? entry.owner_id
249
+ : null;
250
+ const isOwnerCaller = !!authCtx && !!ownerId && authCtx.userId === ownerId;
251
+ if (isLegacyMasterCaller || isAdminCaller || isOwnerCaller) return true;
252
+ if (ownerId === null && DEV_OPEN) return true;
253
+ return false;
254
+ }
255
+
216
256
  type RestNetworkScope = {
217
257
  networkId: string | null;
218
258
  networkIds: string[] | null;
@@ -1619,14 +1659,27 @@ return Bun.serve({
1619
1659
  }));
1620
1660
  }
1621
1661
 
1622
- // ── REST: file download (#221) ──
1623
- // GET /api/files/:file_id with Bearer auth. Always forces
1624
- // Content-Disposition: attachment + X-Content-Type-Options: nosniff
1625
- // so the served file can never be executed or rendered as HTML.
1626
- // The file_id is validated against the same strict regex used at
1627
- // generation time before any filesystem access.
1662
+ // ── REST: file download (#221 + #495 + #500 CR2 HEAD) ──
1663
+ // GET or HEAD /api/files/:file_id with Bearer auth + ownership
1664
+ // check. Always forces Content-Disposition: attachment +
1665
+ // X-Content-Type-Options: nosniff so the served file can never be
1666
+ // executed or rendered as HTML. The file_id is validated against
1667
+ // the same strict regex used at generation time before any
1668
+ // filesystem access.
1669
+ //
1670
+ // Ownership gate is factored into `authorizeFileDownload` (see
1671
+ // src/server.ts near resolveRequestAuth). HEAD MUST route through
1672
+ // the same helper — pre-#500-CR2, HEAD fell through to a fallback
1673
+ // 200 page whose body happened to be byte-identical to the
1674
+ // "unknown file" case, so no enumeration oracle formed in practice.
1675
+ // That was coincidence, not a gate. A change to the fallback (e.g.
1676
+ // adding "Requested resource: <hint>") would materialize the oracle
1677
+ // with zero failing tests. Any future Range, conditional-request,
1678
+ // or dashboard-proxy path for /api/files MUST route through this
1679
+ // handler (via authorizeFileDownload) — do not duplicate the
1680
+ // allow-list inline, or the branches will drift.
1628
1681
  const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
1629
- if (fileMatch && req.method === "GET") {
1682
+ if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
1630
1683
  const authErr = requireAuth(req);
1631
1684
  if (authErr) return withCors(req, authErr);
1632
1685
 
@@ -1648,6 +1701,15 @@ return Bun.serve({
1648
1701
  return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1649
1702
  }
1650
1703
 
1704
+ // #495 ownership gate — placed AFTER file-exists so denied
1705
+ // callers cannot distinguish "you don't own this" from
1706
+ // "no such file". Both branches return the same 404 shape
1707
+ // for BOTH GET and HEAD (HEAD honours body-omission but the
1708
+ // status code and headers are the authoritative signal).
1709
+ if (!authorizeFileDownload(req, entry)) {
1710
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1711
+ }
1712
+
1651
1713
  let storage;
1652
1714
  try {
1653
1715
  storage = buildStoragePath(entry.file_id, entry.ext);
@@ -1675,7 +1737,6 @@ return Bun.serve({
1675
1737
  const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
1676
1738
  ? entry.name
1677
1739
  : `${fileId}${entry.ext}`;
1678
- const blob = Bun.file(storage.absolutePath);
1679
1740
  const responseHeaders: Record<string, string> = {
1680
1741
  ...corsHeaders(req),
1681
1742
  "Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
@@ -1684,6 +1745,15 @@ return Bun.serve({
1684
1745
  "X-Content-Type-Options": "nosniff",
1685
1746
  "Cache-Control": "private, no-store",
1686
1747
  };
1748
+ // HEAD: return the same status and headers as GET, but no body,
1749
+ // per RFC 9110 §9.3.2. This ensures HEAD passes through the same
1750
+ // authorization helper — a cross-owner HEAD is 404 (same as GET),
1751
+ // not a fallback 200. Status/headers are the authoritative
1752
+ // signal; body omission is HTTP-standard for HEAD.
1753
+ if (req.method === "HEAD") {
1754
+ return new Response(null, { status: 200, headers: responseHeaders });
1755
+ }
1756
+ const blob = Bun.file(storage.absolutePath);
1687
1757
  return new Response(blob, { status: 200, headers: responseHeaders });
1688
1758
  }
1689
1759
 
@@ -2129,6 +2199,115 @@ return Bun.serve({
2129
2199
  // legacy master token). Value passes validateAvatarUrl (http/https
2130
2200
  // only, ≤ 2048 chars, no control chars — XSS boundary, see
2131
2201
  // avatar-validate.ts).
2202
+ // ── REST: node DISPLAY attributes (display_name / team / tags) ──
2203
+ // Companion to the avatar route below: hub-side presentation fields that
2204
+ // agent-node does not consume, so they deliberately bypass the RFC-024
2205
+ // config-apply pipeline (no doorbell, no ack, no config_revision bump).
2206
+ //
2207
+ // Unlike avatar, these are multi-field and multi-editor, so they carry
2208
+ // their OWN optimistic lock (`attrs_revision`). A stale base revision is
2209
+ // a 409 with the current value, mirroring update_node_config's contract
2210
+ // so the dashboard can reuse its conflict-resolution UX.
2211
+ //
2212
+ // NOT editable here: `alias` (message-routing key) and `node_name`
2213
+ // (addressing key). Renaming those must go through the rename 2PC at
2214
+ // /api/node-rename/* which cascades sessions + api_tokens + SSE cleanup;
2215
+ // a single-table UPDATE would strand the node (see #146).
2216
+ const nodeAttrsMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/attrs$/);
2217
+ if (nodeAttrsMatch && req.method === "PUT") {
2218
+ let attrsBody: any;
2219
+ try {
2220
+ attrsBody = await req.json();
2221
+ } catch {
2222
+ return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
2223
+ }
2224
+ const ref = decodeURIComponent(nodeAttrsMatch[1]);
2225
+ const params: any[] = [ref, ref, ref];
2226
+ let sql = "SELECT node_id, alias, network_id, display_name, team, tags, attrs_revision FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2227
+ sql = addNetworkScope(sql, params, restScope);
2228
+ sql += " ORDER BY updated_at DESC LIMIT 1";
2229
+ const node = db.get<any>(sql, ...params);
2230
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
2231
+
2232
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
2233
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
2234
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2235
+ }
2236
+
2237
+ // Optimistic lock BEFORE any narrowing work, so a losing writer never
2238
+ // has side effects.
2239
+ const currentRev = Number(node.attrs_revision ?? 0);
2240
+ const baseRev = attrsBody?.base_attrs_revision;
2241
+ if (typeof baseRev !== "number" || !Number.isInteger(baseRev) || baseRev < 0) {
2242
+ return withCors(req, Response.json({
2243
+ ok: false, error: "base_attrs_revision required (integer >= 0)",
2244
+ }, { status: 400 }));
2245
+ }
2246
+ if (baseRev !== currentRev) {
2247
+ return withCors(req, Response.json({
2248
+ ok: false,
2249
+ error: "attrs_revision_conflict",
2250
+ current_attrs_revision: currentRev,
2251
+ message: "another editor changed this node's attributes; re-read and retry",
2252
+ }, { status: 409 }));
2253
+ }
2254
+
2255
+ // Scalars reject on wrong type (never coerce); tags drop junk per item.
2256
+ const nextDisplay = "display_name" in attrsBody
2257
+ ? validateScalarAttr(attrsBody.display_name, "display_name")
2258
+ : null;
2259
+ if (nextDisplay && !nextDisplay.ok) {
2260
+ return withCors(req, Response.json({ ok: false, error: "invalid_display_name", reason: nextDisplay.reason }, { status: 400 }));
2261
+ }
2262
+ const nextTeam = "team" in attrsBody ? validateScalarAttr(attrsBody.team, "team") : null;
2263
+ if (nextTeam && !nextTeam.ok) {
2264
+ return withCors(req, Response.json({ ok: false, error: "invalid_team", reason: nextTeam.reason }, { status: 400 }));
2265
+ }
2266
+ const nextTags = "tags" in attrsBody ? narrowTags(attrsBody.tags) : null;
2267
+
2268
+ // Only the supplied fields move; the rest keep their stored value.
2269
+ const sets: string[] = [];
2270
+ const vals: any[] = [];
2271
+ if (nextDisplay) { sets.push(`display_name = ?${vals.length + 1}`); vals.push(nextDisplay.value); }
2272
+ if (nextTeam) { sets.push(`team = ?${vals.length + 1}`); vals.push(nextTeam.value); }
2273
+ if (nextTags) { sets.push(`tags = ?${vals.length + 1}`); vals.push(JSON.stringify(nextTags)); }
2274
+ // A no-op patch still bumps the revision: the caller observed a state
2275
+ // and asserted it, so honouring the CAS keeps client counters in step.
2276
+ sets.push(`attrs_revision = ?${vals.length + 1}`); vals.push(currentRev + 1);
2277
+ sets.push(`updated_at = datetime('now')`);
2278
+ vals.push(node.node_id);
2279
+ // Guard the UPDATE with the same revision so two concurrent writers
2280
+ // that both passed the read above cannot both land.
2281
+ vals.push(currentRev);
2282
+ const res = db.run(
2283
+ `UPDATE nodes SET ${sets.join(", ")} WHERE node_id = ?${vals.length - 1} AND attrs_revision = ?${vals.length}`,
2284
+ vals,
2285
+ );
2286
+ if (!res.changes) {
2287
+ const fresh = db.get<any>("SELECT attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2288
+ return withCors(req, Response.json({
2289
+ ok: false,
2290
+ error: "attrs_revision_conflict",
2291
+ current_attrs_revision: Number(fresh?.attrs_revision ?? currentRev),
2292
+ message: "another editor changed this node's attributes; re-read and retry",
2293
+ }, { status: 409 }));
2294
+ }
2295
+ logAudit(restAuth?.userId ?? null, restAuth?.username ?? null, "node_attrs_updated", "node", node.node_id,
2296
+ JSON.stringify({ display_name: nextDisplay?.value ?? undefined, team: nextTeam?.value ?? undefined, tags: nextTags ?? undefined }),
2297
+ undefined, nodeNetId ?? undefined);
2298
+ const after = db.get<any>(
2299
+ "SELECT display_name, team, tags, attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2300
+ return withCors(req, Response.json({
2301
+ ok: true,
2302
+ node_id: node.node_id,
2303
+ alias: node.alias,
2304
+ display_name: after?.display_name ?? null,
2305
+ team: after?.team ?? null,
2306
+ tags: parseStoredTags(after?.tags),
2307
+ attrs_revision: Number(after?.attrs_revision ?? currentRev + 1),
2308
+ }));
2309
+ }
2310
+
2132
2311
  const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
2133
2312
  if (nodeAvatarMatch && req.method === "PUT") {
2134
2313
  let avatarBody: any;
@@ -2315,7 +2494,8 @@ return Bun.serve({
2315
2494
  let sql = `SELECT node_id, node_name, alias, runtime, model,
2316
2495
  config_path, channels, server, hostname,
2317
2496
  network_id, created_at, updated_at,
2318
- config_snapshot, lifecycle_state, avatar_url
2497
+ config_snapshot, lifecycle_state, avatar_url,
2498
+ display_name, team, tags, attrs_revision
2319
2499
  FROM nodes WHERE 1=1`;
2320
2500
  const params: any[] = [];
2321
2501
  sql = addNetworkScope(sql, params, restScope);
@@ -2337,7 +2517,16 @@ return Bun.serve({
2337
2517
  catch { /* malformed snapshot — leave role null */ }
2338
2518
  }
2339
2519
  const { config_snapshot, ...rest } = r;
2340
- return { ...rest, role };
2520
+ // `tags` is stored as JSON text; hand the dashboard a real array so
2521
+ // it can map() without a guard, and normalise legacy/absent values
2522
+ // to [] rather than null. attrs_revision is normalised to a number
2523
+ // so pre-migration rows (NULL) read as 0.
2524
+ return {
2525
+ ...rest,
2526
+ tags: parseStoredTags(r.tags),
2527
+ attrs_revision: Number(r.attrs_revision ?? 0),
2528
+ role,
2529
+ };
2341
2530
  });
2342
2531
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
2343
2532
  }
package/src/tools.ts CHANGED
@@ -1058,7 +1058,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1058
1058
  // ── V2: send_reply (关联 task_id,不触发 think) ──
1059
1059
  server.tool(
1060
1060
  "send_reply",
1061
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
1061
+ "Send a reply to a Dashboard/UI-originated task (updates task row, emits new_reply SSE — Dashboard reads it). ⚠ NOT for agent-to-agent replies: agent nodes only log new_reply and do not processInbox on it, so the peer will not see the reply in real time. Use send_task for peer-to-peer replies (RFC-030, Vincent 2026-07-28 全网规则). If the target alias resolves to a live agent node, the response includes a `warning` field pointing to send_task.",
1062
1062
  {
1063
1063
  alias: z.string().min(1).max(200).describe("Target session alias"),
1064
1064
  text: z.string().min(1).max(10000).describe("Reply content"),
@@ -1191,10 +1191,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1191
1191
  // #461 network observer summary — ids + routing only, no reply text.
1192
1192
  pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
1193
1193
 
1194
+ // #498 — When the reply target is a live agent node (not Dashboard/UI),
1195
+ // warn the caller that agent peers only log new_reply and do not
1196
+ // processInbox on it. Points at the correct next action (send_task)
1197
+ // so a follow-up turn corrects course rather than "why didn't they
1198
+ // respond" spelunking. Absent field when target is Dashboard/hub/api
1199
+ // (the correct path for commhub_reply) — silence is affirmation.
1200
+ const targetIsAgent = replyTargetNodeId !== null && alias !== "hub" && alias !== "api";
1201
+ const warning = targetIsAgent
1202
+ ? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
1203
+ : undefined;
1204
+
1194
1205
  return {
1195
1206
  content: [{
1196
1207
  type: "text" as const,
1197
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
1208
+ text: JSON.stringify({
1209
+ ok: true,
1210
+ message_id: id,
1211
+ session_status: session?.status ?? "unknown",
1212
+ ...(warning ? { warning } : {}),
1213
+ }),
1198
1214
  }],
1199
1215
  };
1200
1216
  }