@sleep2agi/commhub-server 0.9.0-preview.12 → 0.9.0-preview.13

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.12",
4
- "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
3
+ "version": "0.9.0-preview.13",
4
+ "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "bin": {
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  isPathInsideUploadsRoot,
21
21
  sanitizeExt,
22
22
  sharedUploadRateLimiter,
23
+ stripHostLocalPathsForCrossHostSafe,
23
24
  validateAttachments,
24
25
  validateIndexEntry,
25
26
  } from "./uploads.js";
@@ -74,7 +75,12 @@ console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error
74
75
 
75
76
  function normalizeMetaJson(meta: unknown): string | null {
76
77
  if (!meta || typeof meta !== "object") return null;
77
- try { return JSON.stringify(meta); } catch { return null; }
78
+ // #222 cross-host safety — same sanitization as tools.ts so REST
79
+ // /api/task and MCP send_task produce identical meta_json shapes.
80
+ // Conditional: only strips `path` when `file_id` is also present
81
+ // (preserves single-host feishu fallback per [[feedback_assume_unit_before_threshold]]
82
+ // discipline of not breaking other call sites).
83
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
78
84
  }
79
85
 
80
86
  // ── Rate limiter (in-memory, per IP) ──
package/src/tools.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
31
31
  VaultError,
32
32
  } from "./vault.js";
33
+ import { stripHostLocalPathsForCrossHostSafe } from "./uploads.js";
33
34
  import {
34
35
  validateBaseUrl as _validateBaseUrl,
35
36
  SUPPORTED_VENDORS,
@@ -63,7 +64,12 @@ function parseMetaJson(value: unknown): unknown | null {
63
64
 
64
65
  function normalizeMetaJson(meta: unknown): string | null {
65
66
  if (!meta || typeof meta !== "object") return null;
66
- try { return JSON.stringify(meta); } catch { return null; }
67
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
68
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
69
+ // REST handler in index.ts can apply the identical sanitization
70
+ // (otherwise REST and MCP transports would have different meta_json
71
+ // shapes for the same send).
72
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
67
73
  }
68
74
 
69
75
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
@@ -312,3 +312,69 @@ describe("indexEntryPath", () => {
312
312
  expect(p).toBe(`/srv/up/.index/${id}.json`);
313
313
  });
314
314
  });
315
+
316
+ import { stripHostLocalPathsForCrossHostSafe } from "./uploads";
317
+
318
+ describe("stripHostLocalPathsForCrossHostSafe (#222 cross-host attachment safety)", () => {
319
+ test("strips path when file_id is present (cross-host safe — agent will fetch via /api/files/<id>)", () => {
320
+ const meta = {
321
+ attachments: [
322
+ { type: "file", file_id: "abc12345", path: "/home/hub/uploads/abc12345.png", mime: "image/png" },
323
+ ],
324
+ otherKey: "preserved",
325
+ };
326
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
327
+ expect(out.attachments[0]).toEqual({ type: "file", file_id: "abc12345", mime: "image/png" });
328
+ expect(out.attachments[0].path).toBeUndefined();
329
+ expect(out.otherKey).toBe("preserved");
330
+ });
331
+
332
+ test("PRESERVES path when file_id is absent (feishu single-host fallback)", () => {
333
+ // 🔴 通信龙 PR #222 pre-review nit — feishu-bridge ships path-only
334
+ // attachments (writes /work/feishu-attachments/...). If we strip
335
+ // unconditionally those break too. Lock the conditional contract.
336
+ const meta = {
337
+ attachments: [
338
+ { type: "file", path: "/work/feishu-attachments/feishu-local/oc_xxx/img.jpg", mime: "image/jpeg" },
339
+ ],
340
+ };
341
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
342
+ expect(out.attachments[0].path).toBe("/work/feishu-attachments/feishu-local/oc_xxx/img.jpg");
343
+ expect(out.attachments[0].file_id).toBeUndefined();
344
+ });
345
+
346
+ test("mixed array: strips path on entries with file_id, preserves on path-only entries", () => {
347
+ const meta = {
348
+ attachments: [
349
+ { type: "file", file_id: "id1xxxxx", path: "/hub/local/id1.png", mime: "image/png" },
350
+ { type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" },
351
+ { type: "file", file_id: "id2yyyyy", mime: "image/gif" }, // already path-free
352
+ ],
353
+ };
354
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
355
+ expect(out.attachments[0]).toEqual({ type: "file", file_id: "id1xxxxx", mime: "image/png" });
356
+ expect(out.attachments[1]).toEqual({ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" });
357
+ expect(out.attachments[2]).toEqual({ type: "file", file_id: "id2yyyyy", mime: "image/gif" });
358
+ });
359
+
360
+ test("no-op when meta has no attachments array", () => {
361
+ const meta = { something: "else" };
362
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
363
+ });
364
+
365
+ test("no-op when nothing to strip (returns SAME object reference, not a copy)", () => {
366
+ const meta = { attachments: [{ type: "file", file_id: "x12345xx", mime: "image/png" }] };
367
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
368
+ });
369
+
370
+ test("handles null / non-object meta gracefully", () => {
371
+ expect(stripHostLocalPathsForCrossHostSafe(null)).toBe(null);
372
+ expect(stripHostLocalPathsForCrossHostSafe(undefined as any)).toBe(undefined);
373
+ expect(stripHostLocalPathsForCrossHostSafe("string" as any)).toBe("string");
374
+ });
375
+
376
+ test("handles non-array attachments field gracefully", () => {
377
+ const meta = { attachments: "not-an-array" };
378
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
379
+ });
380
+ });
package/src/uploads.ts CHANGED
@@ -241,6 +241,42 @@ export type TaskAttachment = {
241
241
  size?: number;
242
242
  };
243
243
 
244
+ /**
245
+ * #222 cross-host attachment safety — strip `path` from meta.attachments
246
+ * entries that ALSO carry a `file_id`. Reason: the hub's /api/upload
247
+ * returns `{file_id, path}` where `path` is a hub-machine-local
248
+ * absolute path. Senders sometimes echo `path` back into meta.attachments.
249
+ * When the receiving agent runs on a different host (Vincent 2026-06-30
250
+ * toodadev2 case), that path string is meaningless. agent-node's
251
+ * fetch-attachment.ts will fetch via `file_id` regardless, but leaving
252
+ * the stale `path` in the JSON misleads any tooling that reads it.
253
+ *
254
+ * CRITICAL — conditional, not unconditional (通信龙 PR #222 pre-review
255
+ * nit): feishu-bridge currently writes `/work/feishu-attachments/<conn>/
256
+ * <chat>/` directly and ships path-only attachments (NO file_id). If we
257
+ * strip path unconditionally those break too. Only strip when file_id
258
+ * is present (the "safe to drop" signal). Single-host feishu fallback
259
+ * preserved.
260
+ *
261
+ * Shared by tools.ts (MCP send_task) and index.ts (REST /api/task) so
262
+ * both transports apply the same sanitization to meta.
263
+ */
264
+ export function stripHostLocalPathsForCrossHostSafe(meta: any): any {
265
+ if (!meta || typeof meta !== "object") return meta;
266
+ if (!Array.isArray((meta as any).attachments)) return meta;
267
+ let mutated = false;
268
+ const sanitized = (meta as any).attachments.map((a: any) => {
269
+ if (a && typeof a === "object" && typeof a.file_id === "string" && a.file_id && typeof a.path === "string") {
270
+ mutated = true;
271
+ const { path: _stripped, ...rest } = a;
272
+ return rest;
273
+ }
274
+ return a;
275
+ });
276
+ if (!mutated) return meta;
277
+ return { ...meta, attachments: sanitized };
278
+ }
279
+
244
280
  /** Light validator usable from the REST handler. Returns null on success or an error message string. */
245
281
  export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
246
282
  if (input === undefined || input === null) return { ok: true, attachments: [] };