@sleep2agi/commhub-server 0.9.0-preview.21 → 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/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/api-nodes-shape.test.ts +26 -10
- 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/db.ts +42 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- 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/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +2831 -0
- package/src/tools.ts +26 -3
- package/src/uploads-http.test.ts +11 -8
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Boundary validation for node DISPLAY attributes (display_name / team /
|
|
2
|
+
// tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
|
|
3
|
+
// that decides what a client is allowed to store, and it trusts nothing.
|
|
4
|
+
//
|
|
5
|
+
// Two different postures on purpose:
|
|
6
|
+
//
|
|
7
|
+
// display_name / team — SCALAR fields the user typed. A wrong TYPE here
|
|
8
|
+
// is a client bug, so it is REJECTED (400) rather than coerced: silently
|
|
9
|
+
// turning `{}` into "[object Object]" would persist garbage the user
|
|
10
|
+
// never typed and can't explain.
|
|
11
|
+
//
|
|
12
|
+
// tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
|
|
13
|
+
// wire contract wants junk dropped instead of failing the whole request,
|
|
14
|
+
// so one fat-fingered entry from a dashboard doesn't 400 the save. Each
|
|
15
|
+
// element is narrowed independently (typeof → trim → length cap →
|
|
16
|
+
// dedup), so nothing non-string can reach the database.
|
|
17
|
+
|
|
18
|
+
/** Max stored length of display_name / team. */
|
|
19
|
+
export const MAX_ATTR_LEN = 120;
|
|
20
|
+
/** Max stored length of a single tag. */
|
|
21
|
+
export const MAX_TAG_LEN = 64;
|
|
22
|
+
/** Max number of tags kept per node. */
|
|
23
|
+
export const MAX_TAGS = 16;
|
|
24
|
+
|
|
25
|
+
export type ScalarAttrResult =
|
|
26
|
+
| { ok: true; value: string | null }
|
|
27
|
+
| { ok: false; reason: string };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Narrow an optional scalar display attribute.
|
|
31
|
+
* undefined → not part of this patch (caller leaves the column alone)
|
|
32
|
+
* null / "" → explicit clear
|
|
33
|
+
* non-string → rejected (never coerced)
|
|
34
|
+
* over-long → rejected (the user should see it, not silently lose text)
|
|
35
|
+
*/
|
|
36
|
+
export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
|
|
37
|
+
if (raw === undefined) return { ok: true, value: null };
|
|
38
|
+
if (raw === null) return { ok: true, value: null };
|
|
39
|
+
if (typeof raw !== "string") {
|
|
40
|
+
return { ok: false, reason: `${field} must be a string or null` };
|
|
41
|
+
}
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed.length === 0) return { ok: true, value: null };
|
|
44
|
+
if (trimmed.length > MAX_ATTR_LEN) {
|
|
45
|
+
return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
|
|
46
|
+
}
|
|
47
|
+
// Control characters would corrupt any log line / table cell that renders
|
|
48
|
+
// the value; strip rather than reject (they are almost always a paste
|
|
49
|
+
// artefact, not user intent).
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
52
|
+
return { ok: true, value: clean.length > 0 ? clean : null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Narrow an untrusted tags array. NEVER throws, never rejects the request:
|
|
57
|
+
* unusable entries are dropped. Returns the canonical list to store.
|
|
58
|
+
*/
|
|
59
|
+
export function narrowTags(raw: unknown): string[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const item of raw) {
|
|
64
|
+
if (typeof item !== "string") continue; // numbers/objects/arrays/null
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
67
|
+
if (t.length === 0) continue;
|
|
68
|
+
if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
|
|
69
|
+
const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
|
|
70
|
+
if (seen.has(key)) continue;
|
|
71
|
+
seen.add(key);
|
|
72
|
+
out.push(t);
|
|
73
|
+
if (out.length >= MAX_TAGS) break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse the stored tags column back into an array. Storage is JSON text;
|
|
79
|
+
* anything unparseable (hand-edited DB, older format) reads as empty so a
|
|
80
|
+
* dashboard can always `.map()` without a guard. */
|
|
81
|
+
export function parseStoredTags(stored: unknown): string[] {
|
|
82
|
+
if (typeof stored !== "string" || stored.length === 0) return [];
|
|
83
|
+
try {
|
|
84
|
+
return narrowTags(JSON.parse(stored));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
|
|
2
|
+
//
|
|
3
|
+
// These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
|
|
4
|
+
// precedent they are deliberately NOT part of the RFC-024 config-apply
|
|
5
|
+
// pipeline: agent-node has nothing to consume, so pushing them through
|
|
6
|
+
// config revision/ack would ring a doorbell for a field the node does not
|
|
7
|
+
// understand and could wedge the config revision.
|
|
8
|
+
//
|
|
9
|
+
// They DO need concurrency protection (two dashboards editing the same
|
|
10
|
+
// node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
|
|
11
|
+
// that is independent of `config_revision` — editing a tag must never bump
|
|
12
|
+
// the node's *config* revision.
|
|
13
|
+
//
|
|
14
|
+
// Every test drives the REAL HTTP endpoint against a booted server, not a
|
|
15
|
+
// helper: the contract under test is the wire shape + status codes.
|
|
16
|
+
//
|
|
17
|
+
// NOTE (test isolation): this file boots its own Bun.serve, same pattern as
|
|
18
|
+
// uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
|
|
19
|
+
// are per-file runs by design (single-process module-singleton conflict,
|
|
20
|
+
// tracked in #434) — run this file on its own.
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync } from "fs";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
|
|
27
|
+
const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
|
|
28
|
+
process.env.COMMHUB_DB = tmpDb;
|
|
29
|
+
const PORT = 23000 + Math.floor(Math.random() * 1500);
|
|
30
|
+
process.env.PORT = String(PORT);
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
|
|
33
|
+
const { db } = await import("./db.js");
|
|
34
|
+
const { issueUserToken } = await import("./auth.js");
|
|
35
|
+
const { bootServer } = await import("./server.js");
|
|
36
|
+
|
|
37
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
38
|
+
const NET = "net_attrs";
|
|
39
|
+
const USER = "u_attrs";
|
|
40
|
+
/** A node row seeded the way a PRE-MIGRATION row looks: none of the new
|
|
41
|
+
* columns are written, so they are NULL / default at read time. */
|
|
42
|
+
const LEGACY_NODE = "node_attrs_legacy";
|
|
43
|
+
const LEGACY_ALIAS = "attrs-legacy-node";
|
|
44
|
+
|
|
45
|
+
let token = "";
|
|
46
|
+
let server: { stop: (force?: boolean) => void } | null = null;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
|
|
50
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
|
|
51
|
+
db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
|
|
52
|
+
// Legacy row: only the columns that existed before this change.
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
|
|
55
|
+
VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
|
|
56
|
+
[LEGACY_NODE, LEGACY_ALIAS, NET],
|
|
57
|
+
);
|
|
58
|
+
token = issueUserToken(USER, "attrs-test").token;
|
|
59
|
+
server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
|
|
60
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
try { server?.stop(true); } catch { /* already down */ }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function putAttrs(ref: string, body: unknown) {
|
|
68
|
+
const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
|
|
69
|
+
method: "PUT",
|
|
70
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function listNodes() {
|
|
77
|
+
const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
79
|
+
});
|
|
80
|
+
const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
|
|
81
|
+
return j.nodes ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("node display attrs — backward compatibility with existing rows", () => {
|
|
85
|
+
test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
|
|
86
|
+
const rows = await listNodes();
|
|
87
|
+
const row = rows.find((r) => r.node_id === LEGACY_NODE);
|
|
88
|
+
expect(row).toBeDefined();
|
|
89
|
+
// Must not blow up and must not omit the keys — the dashboard reads
|
|
90
|
+
// them unconditionally.
|
|
91
|
+
expect(row!).toHaveProperty("display_name");
|
|
92
|
+
expect(row!).toHaveProperty("team");
|
|
93
|
+
expect(row!).toHaveProperty("tags");
|
|
94
|
+
expect(row!.display_name ?? null).toBeNull();
|
|
95
|
+
expect(row!.team ?? null).toBeNull();
|
|
96
|
+
// tags normalises to an empty array rather than null/undefined so the
|
|
97
|
+
// dashboard can `.map()` without a guard.
|
|
98
|
+
expect(row!.tags).toEqual([]);
|
|
99
|
+
expect(row!.attrs_revision).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("node display attrs — write then read back", () => {
|
|
104
|
+
test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
|
|
105
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
106
|
+
base_attrs_revision: 0,
|
|
107
|
+
display_name: "研发一号机",
|
|
108
|
+
team: "platform",
|
|
109
|
+
tags: ["prod", "gpu"],
|
|
110
|
+
});
|
|
111
|
+
expect(w.status).toBe(200);
|
|
112
|
+
expect(w.json.ok).toBe(true);
|
|
113
|
+
expect(w.json.attrs_revision).toBe(1);
|
|
114
|
+
|
|
115
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
116
|
+
expect(row.display_name).toBe("研发一号机");
|
|
117
|
+
expect(row.team).toBe("platform");
|
|
118
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
119
|
+
expect(row.attrs_revision).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
|
|
123
|
+
const before = db.get<{ config_revision: number }>(
|
|
124
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
125
|
+
const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
|
|
126
|
+
expect(w.status).toBe(200);
|
|
127
|
+
const after = db.get<{ config_revision: number }>(
|
|
128
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
129
|
+
expect(after).toBe(before);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("partial patch leaves untouched fields alone", async () => {
|
|
133
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
134
|
+
// team was just changed to 'infra'; display_name/tags must survive.
|
|
135
|
+
expect(row.team).toBe("infra");
|
|
136
|
+
expect(row.display_name).toBe("研发一号机");
|
|
137
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("node display attrs — CAS concurrency guard", () => {
|
|
142
|
+
test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
|
|
143
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
144
|
+
const stale = await putAttrs(LEGACY_ALIAS, {
|
|
145
|
+
base_attrs_revision: 0, // deliberately stale
|
|
146
|
+
team: "should-not-land",
|
|
147
|
+
});
|
|
148
|
+
expect(stale.status).toBe(409);
|
|
149
|
+
expect(stale.json.ok).toBe(false);
|
|
150
|
+
expect(stale.json.error).toBe("attrs_revision_conflict");
|
|
151
|
+
expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
|
|
152
|
+
|
|
153
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
154
|
+
expect(after.team).toBe(before.team); // unchanged
|
|
155
|
+
expect(after.attrs_revision).toBe(before.attrs_revision);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("node display attrs — untrusted input is narrowed at the boundary", () => {
|
|
160
|
+
test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
|
|
161
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
162
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
163
|
+
base_attrs_revision: cur,
|
|
164
|
+
tags: [
|
|
165
|
+
"keep",
|
|
166
|
+
"keep", // duplicate
|
|
167
|
+
" spaced ", // trimmed
|
|
168
|
+
"",
|
|
169
|
+
" ",
|
|
170
|
+
42,
|
|
171
|
+
null,
|
|
172
|
+
{ evil: true },
|
|
173
|
+
["nested"],
|
|
174
|
+
"x".repeat(200), // over-long → dropped or truncated, never stored raw
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
expect(w.status).toBe(200);
|
|
178
|
+
const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
|
|
179
|
+
expect(Array.isArray(tags)).toBe(true);
|
|
180
|
+
expect(tags).toContain("keep");
|
|
181
|
+
expect(tags).toContain("spaced");
|
|
182
|
+
// one "keep", no junk, nothing absurdly long
|
|
183
|
+
expect(tags.filter((t) => t === "keep")).toHaveLength(1);
|
|
184
|
+
expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
|
|
185
|
+
expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("display_name/team: non-string values are rejected, not coerced", async () => {
|
|
189
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
190
|
+
const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
|
|
191
|
+
expect(bad.status).toBe(400);
|
|
192
|
+
expect(bad.json.ok).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("node display attrs — auth + scope", () => {
|
|
197
|
+
test("no token → 401, and the row is untouched", async () => {
|
|
198
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
199
|
+
const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
|
|
200
|
+
method: "PUT",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(401);
|
|
205
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
206
|
+
expect(after.team).toBe(before.team);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("unknown node ref → 404", async () => {
|
|
210
|
+
const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
|
|
211
|
+
expect(r.status).toBe(404);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// #461 + #462 — HTTP integration tests against the real Bun.serve hub.
|
|
2
|
+
//
|
|
3
|
+
// Boots a PRIVATE server instance via the #434 seam (`bootServer({ port: 0 })`,
|
|
4
|
+
// same signature as PR #438) and derives BASE from the actual bound port.
|
|
5
|
+
// This keeps the suite alive in aggregate runs: `await import("./server.js")`
|
|
6
|
+
// is a module-cache no-op when another file booted first, so relying on the
|
|
7
|
+
// import side effect + own PORT env leaves every fetch ConnectionRefused
|
|
8
|
+
// while the tests still "run" (通信龙 #461 review, finding 2). Gate:
|
|
9
|
+
// bun test src/uploads-http.test.ts src/observer-avatar-http.test.ts → 0 fail
|
|
10
|
+
//
|
|
11
|
+
// #461: GET /events/network/:id observer stream — membership auth incl.
|
|
12
|
+
// member-removal revocation, summary events for third-party traffic
|
|
13
|
+
// (REST dispatch + MCP send_reply), no content leakage.
|
|
14
|
+
// #462: PUT /api/nodes/:ref/avatar + GET /api/nodes round trip — persistence,
|
|
15
|
+
// href normalization, XSS-shaped rejects, clear semantics, write gate.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
18
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { tmpdir } from "os";
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { register, login, addNetworkMember, removeNetworkMember, createNetworkTokenForNode } from "./auth.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { db } from "./db.js";
|
|
25
|
+
|
|
26
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-obs-avatar-db-")) + "/commhub.db";
|
|
27
|
+
|
|
28
|
+
let BASE = "";
|
|
29
|
+
let privateServer: any = null;
|
|
30
|
+
|
|
31
|
+
let memberToken = "";
|
|
32
|
+
let memberNetworkId = "";
|
|
33
|
+
let memberUserId = "";
|
|
34
|
+
let outsiderToken = "";
|
|
35
|
+
let outsiderUserId = "";
|
|
36
|
+
const TARGET_ALIAS = "obs-target-agent";
|
|
37
|
+
const AVATAR_NODE_ID = "node_avatar_test_1";
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
// Kept for per-file runs launched without an external COMMHUB_DB (the
|
|
41
|
+
// documented contract still is to set it externally; see db-adapter.ts).
|
|
42
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
43
|
+
process.env.HOST = "127.0.0.1";
|
|
44
|
+
// (#438 corrective: importing server.js is side-effect-free — no
|
|
45
|
+
// default instance, no PORT juggling; only the private bootServer
|
|
46
|
+
// instance below listens.)
|
|
47
|
+
|
|
48
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
49
|
+
const pw = "BootstrapPw123Aa!";
|
|
50
|
+
|
|
51
|
+
const memberName = `obs_member_${suffix}`;
|
|
52
|
+
let r = register(memberName, pw, undefined, "seed");
|
|
53
|
+
if (!r.ok || !r.token) {
|
|
54
|
+
const lr = login(memberName, pw);
|
|
55
|
+
if (lr.token) { memberToken = lr.token; memberNetworkId = lr.network_id ?? ""; }
|
|
56
|
+
} else {
|
|
57
|
+
memberToken = r.token;
|
|
58
|
+
memberNetworkId = r.network_id ?? "";
|
|
59
|
+
}
|
|
60
|
+
expect(memberToken).toBeTruthy();
|
|
61
|
+
expect(memberNetworkId).toBeTruthy();
|
|
62
|
+
memberUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", memberName)?.user_id ?? "";
|
|
63
|
+
expect(memberUserId).toBeTruthy();
|
|
64
|
+
|
|
65
|
+
const outsiderName = `obs_outsider_${suffix}`;
|
|
66
|
+
const r2 = register(outsiderName, pw, undefined, "seed");
|
|
67
|
+
if (r2.ok && r2.token) outsiderToken = r2.token;
|
|
68
|
+
expect(outsiderToken).toBeTruthy();
|
|
69
|
+
outsiderUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", outsiderName)?.user_id ?? "";
|
|
70
|
+
expect(outsiderUserId).toBeTruthy();
|
|
71
|
+
|
|
72
|
+
// Target agent session in the member's network, freshly seen → online.
|
|
73
|
+
db.run(
|
|
74
|
+
`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
|
|
75
|
+
VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`,
|
|
76
|
+
[`resume_${suffix}`, TARGET_ALIAS, memberNetworkId]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Node row for the avatar round trip.
|
|
80
|
+
db.run(
|
|
81
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
82
|
+
VALUES (?1, 'avatar-test-node', ?2, ?3, datetime('now'), datetime('now'))`,
|
|
83
|
+
[AVATAR_NODE_ID, TARGET_ALIAS, memberNetworkId]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Private instance on an OS-assigned port — collision-free by
|
|
87
|
+
// construction (kernel-assigned).
|
|
88
|
+
const mod: any = await import("./server.js");
|
|
89
|
+
privateServer = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
|
+
BASE = `http://127.0.0.1:${privateServer.port}`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
try { privateServer?.stop?.(true); } catch {}
|
|
95
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function auth(token: string): Record<string, string> {
|
|
99
|
+
return { "Authorization": `Bearer ${token}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the next SSE data frame from a fetch Response, with timeout. */
|
|
103
|
+
async function readFrame(
|
|
104
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
105
|
+
timeoutMs = 3_000,
|
|
106
|
+
): Promise<Record<string, any>> {
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
let buf = "";
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const result = await Promise.race([
|
|
112
|
+
reader.read(),
|
|
113
|
+
new Promise<never>((_, rej) =>
|
|
114
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
|
|
115
|
+
),
|
|
116
|
+
]);
|
|
117
|
+
if (result.done) throw new Error("SSE stream ended unexpectedly");
|
|
118
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
119
|
+
const sep = buf.indexOf("\n\n");
|
|
120
|
+
if (sep === -1) continue;
|
|
121
|
+
const rawFrame = buf.slice(0, sep);
|
|
122
|
+
buf = buf.slice(sep + 2);
|
|
123
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
124
|
+
if (!dataLine) continue;
|
|
125
|
+
return JSON.parse(dataLine.slice(6));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("#461 GET /events/network/:id — auth", () => {
|
|
131
|
+
test("anonymous → 401", async () => {
|
|
132
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`);
|
|
133
|
+
expect(res.status).toBe(401);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("non-member user token → 403", async () => {
|
|
137
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
138
|
+
expect(res.status).toBe(403);
|
|
139
|
+
const body = await res.json() as any;
|
|
140
|
+
expect(body.ok).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("member user token → SSE stream with observer connected frame", async () => {
|
|
144
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(res.headers.get("content-type") || "").toContain("text/event-stream");
|
|
147
|
+
const reader = res.body!.getReader();
|
|
148
|
+
const connected = await readFrame(reader);
|
|
149
|
+
expect(connected.type).toBe("connected");
|
|
150
|
+
expect(connected.observer).toBe(true);
|
|
151
|
+
expect(connected.network_id).toBe(memberNetworkId);
|
|
152
|
+
await reader.cancel();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("membership removal revokes observer access — ntok exploit repro (通信龙 review finding 1)", async () => {
|
|
156
|
+
// Exact repro of the review's exploit: add user C to network A, mint
|
|
157
|
+
// an ntok BOUND to A, remove C from A — the same live ntok must lose
|
|
158
|
+
// the stream. network_members IS the revocation mechanism because
|
|
159
|
+
// removeNetworkMember does not revoke the user's tokens; the old
|
|
160
|
+
// `!role && authCtx.networkId !== observedNetId` OR-form let the
|
|
161
|
+
// token's network binding bypass the membership check (utok would
|
|
162
|
+
// not catch this: its authCtx.networkId is null, so only ntok
|
|
163
|
+
// exercises the vulnerable branch — verified by stubbing the OR
|
|
164
|
+
// back in: THIS test goes red, the utok variants stay green).
|
|
165
|
+
const added = addNetworkMember(memberNetworkId, outsiderUserId, "member");
|
|
166
|
+
expect(added.ok).toBe(true);
|
|
167
|
+
const minted = createNetworkTokenForNode(outsiderUserId, memberNetworkId, "revocation-probe");
|
|
168
|
+
expect(minted.ok).toBe(true);
|
|
169
|
+
const ntok = minted.token!;
|
|
170
|
+
expect(ntok.startsWith("ntok_")).toBe(true);
|
|
171
|
+
|
|
172
|
+
const whileMember = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
173
|
+
expect(whileMember.status).toBe(200);
|
|
174
|
+
const reader = whileMember.body!.getReader();
|
|
175
|
+
expect((await readFrame(reader)).type).toBe("connected");
|
|
176
|
+
await reader.cancel();
|
|
177
|
+
|
|
178
|
+
const removed = removeNetworkMember(memberNetworkId, outsiderUserId);
|
|
179
|
+
expect(removed.ok).toBe(true);
|
|
180
|
+
// The review's failing probe: /events/network/A with the still-live
|
|
181
|
+
// network-bound token → must now be 403, not 200.
|
|
182
|
+
const afterRemoval = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
183
|
+
expect(afterRemoval.status).toBe(403);
|
|
184
|
+
// utok of the removed user is denied too.
|
|
185
|
+
const utokAfter = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
186
|
+
expect(utokAfter.status).toBe(403);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("#461 observer receives third-party traffic summaries", () => {
|
|
191
|
+
test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
|
|
192
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
193
|
+
const reader = res.body!.getReader();
|
|
194
|
+
await readFrame(reader); // connected
|
|
195
|
+
|
|
196
|
+
const secret = `SECRET-TASK-CONTENT-${Date.now()}`;
|
|
197
|
+
const dispatch = await fetch(`${BASE}/api/task`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId }),
|
|
201
|
+
});
|
|
202
|
+
expect([200, 202]).toContain(dispatch.status);
|
|
203
|
+
const dispatched = await dispatch.json() as any;
|
|
204
|
+
expect(dispatched.task_id).toBeTruthy();
|
|
205
|
+
|
|
206
|
+
const evt = await readFrame(reader);
|
|
207
|
+
expect(evt.type).toBe("new_task");
|
|
208
|
+
expect(evt.task_id).toBe(dispatched.task_id);
|
|
209
|
+
expect(evt.from).toBe("third-party-sender");
|
|
210
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
211
|
+
// Online session was seeded → delivered; offline would report queued.
|
|
212
|
+
expect(evt.status).toBe("delivered");
|
|
213
|
+
expect(evt.priority).toBe("high");
|
|
214
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
215
|
+
expect(evt.scope).toBe("network");
|
|
216
|
+
// The acceptance line: summary must NOT leak the task body.
|
|
217
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
218
|
+
await reader.cancel();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("MCP send_reply → observer gets new_reply summary, reply text NOT in frame", async () => {
|
|
222
|
+
// Drive the REAL registered MCP handler (same interception pattern as
|
|
223
|
+
// ack-create-request.test.ts) with the member's network enforced, and
|
|
224
|
+
// read the observer stream over real HTTP.
|
|
225
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
226
|
+
const reader = res.body!.getReader();
|
|
227
|
+
await readFrame(reader); // connected
|
|
228
|
+
|
|
229
|
+
const tools: Record<string, (args: any) => Promise<any>> = {};
|
|
230
|
+
const mcp = new McpServer({ name: "test", version: "0" }) as any;
|
|
231
|
+
mcp.tool = (name: string, _desc: string, _schema: any, handler: any) => { tools[name] = handler; };
|
|
232
|
+
registerTools(mcp, undefined, memberNetworkId, memberUserId);
|
|
233
|
+
expect(typeof tools["send_reply"]).toBe("function");
|
|
234
|
+
|
|
235
|
+
const secret = `SECRET-REPLY-TEXT-${Date.now()}`;
|
|
236
|
+
const result = await tools["send_reply"]({
|
|
237
|
+
alias: TARGET_ALIAS,
|
|
238
|
+
text: secret,
|
|
239
|
+
status: "replied",
|
|
240
|
+
from_session: "reply-worker",
|
|
241
|
+
});
|
|
242
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
243
|
+
expect(parsed.ok).toBe(true);
|
|
244
|
+
|
|
245
|
+
const evt = await readFrame(reader);
|
|
246
|
+
expect(evt.type).toBe("new_reply");
|
|
247
|
+
expect(evt.from).toBe("reply-worker");
|
|
248
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
249
|
+
expect(evt.status).toBe("replied");
|
|
250
|
+
expect(evt.message_id).toBe(parsed.message_id);
|
|
251
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
252
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
253
|
+
await reader.cancel();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("#462 PUT /api/nodes/:ref/avatar + GET /api/nodes", () => {
|
|
258
|
+
test("set avatar → 200, GET /api/nodes returns it (cross-device persistence)", async () => {
|
|
259
|
+
const avatarUrl = "https://cdn.example.com/avatars/custom-1.png";
|
|
260
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({ avatar_url: avatarUrl }),
|
|
264
|
+
});
|
|
265
|
+
expect(put.status).toBe(200);
|
|
266
|
+
const putBody = await put.json() as any;
|
|
267
|
+
expect(putBody.ok).toBe(true);
|
|
268
|
+
expect(putBody.avatar_url).toBe(avatarUrl);
|
|
269
|
+
|
|
270
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
271
|
+
expect(list.status).toBe(200);
|
|
272
|
+
const listBody = await list.json() as any;
|
|
273
|
+
expect(listBody.ok).toBe(true);
|
|
274
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
275
|
+
expect(row).toBeTruthy();
|
|
276
|
+
expect(row.avatar_url).toBe(avatarUrl);
|
|
277
|
+
// #312 discipline still holds — internals must not leak.
|
|
278
|
+
expect("config_snapshot" in row).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("markup-hostile chars are stored NORMALIZED (href), not raw (通信龙 review finding 3)", async () => {
|
|
282
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
283
|
+
method: "PUT",
|
|
284
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify({ avatar_url: 'https://cdn.example.com/a"b.png' }),
|
|
286
|
+
});
|
|
287
|
+
expect(put.status).toBe(200);
|
|
288
|
+
const putBody = await put.json() as any;
|
|
289
|
+
expect(putBody.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
290
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
291
|
+
expect(row.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
292
|
+
expect(row.avatar_url).not.toContain('"');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("clear avatar with null → avatar_url null in list", async () => {
|
|
296
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
297
|
+
method: "PUT",
|
|
298
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
299
|
+
body: JSON.stringify({ avatar_url: null }),
|
|
300
|
+
});
|
|
301
|
+
expect(put.status).toBe(200);
|
|
302
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
303
|
+
const listBody = await list.json() as any;
|
|
304
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
305
|
+
expect(row.avatar_url).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("javascript: URL → 400 invalid_avatar_url, value NOT persisted", async () => {
|
|
309
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
310
|
+
method: "PUT",
|
|
311
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({ avatar_url: "javascript:alert(1)" }),
|
|
313
|
+
});
|
|
314
|
+
expect(put.status).toBe(400);
|
|
315
|
+
const body = await put.json() as any;
|
|
316
|
+
expect(body.error).toBe("invalid_avatar_url");
|
|
317
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
318
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("data: URL → 400", async () => {
|
|
322
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
323
|
+
method: "PUT",
|
|
324
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
325
|
+
body: JSON.stringify({ avatar_url: "data:text/html,<script>alert(1)</script>" }),
|
|
326
|
+
});
|
|
327
|
+
expect(put.status).toBe(400);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("anonymous → 401; non-member → 404/403 (network-scoped lookup)", async () => {
|
|
331
|
+
const anon = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
332
|
+
method: "PUT",
|
|
333
|
+
headers: { "Content-Type": "application/json" },
|
|
334
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
335
|
+
});
|
|
336
|
+
expect(anon.status).toBe(401);
|
|
337
|
+
|
|
338
|
+
// Outsider's network scope can't even see the node → 404 (or 403 if
|
|
339
|
+
// visibility rules change later; both deny the write).
|
|
340
|
+
const outsider = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
341
|
+
method: "PUT",
|
|
342
|
+
headers: { ...auth(outsiderToken), "Content-Type": "application/json" },
|
|
343
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
344
|
+
});
|
|
345
|
+
expect([403, 404]).toContain(outsider.status);
|
|
346
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
347
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
});
|