@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 +2 -2
- package/src/index.ts +7 -1
- package/src/tools.ts +7 -1
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
4
|
-
"description": "CommHub Server
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/uploads.test.ts
CHANGED
|
@@ -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: [] };
|