@sleep2agi/commhub-server 0.9.0-preview.144 → 0.9.0-preview.146

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.
@@ -0,0 +1,254 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { db } from "./db.js";
3
+ import { getUserNetworkRole, resolveToken, sessionIdleDays } from "./auth.js";
4
+ import { isAgentRestricted } from "./agent-access.js";
5
+ import { resolveNodeCaller } from "./create-node.js";
6
+ import { pushEvent } from "./push.js";
7
+
8
+ type Identity = { session_id: string; binding_id: string };
9
+ type Credential = { token_id: string; user_id: string; network_id: string | null;
10
+ bound_node_id: string | null; scope: string; role: string };
11
+ type Node = { node_id: string; network_id: string; owner_user_id: string | null; alias: string };
12
+ type Bridge = Identity & { tokenId: string; expires: number; retired: Set<string> };
13
+ type Entry = Identity & { request_id: string; operation: "state" | "background";
14
+ networkId: string; nodeId: string; tokenId: string; requester: string; expires_at: number;
15
+ status: "pending" | "in_progress" | "done" | "failed" | "unknown" | "expired";
16
+ result?: unknown; error?: string };
17
+ const TTL = 15_000, LEASE = 75_000, CAP = 1024, HISTORY = 64;
18
+ const ROOT = "/api/node-session-control";
19
+ const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
20
+ const ERRORS = new Set(["unavailable", "changed", "unsupported", "invalid_response", "request_invalid",
21
+ "request_expired", "request_limit"].map(s => `session_control_${s}`).concat("session_background_outcome_unknown"));
22
+ const keys = (o: Record<string, unknown>, allowed: string[]) => Object.keys(o).every(k => allowed.includes(k));
23
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
24
+ const id = (v: unknown): v is string => typeof v === "string" && ID.test(v);
25
+ const identity = (v: unknown): v is Identity => object(v) && keys(v, ["session_id", "binding_id"])
26
+ && id(v.session_id) && id(v.binding_id);
27
+ const same = (a: Identity, b: Identity) => a.session_id === b.session_id && a.binding_id === b.binding_id;
28
+ const generation = (token: string, v: Identity) => JSON.stringify([token, v.session_id, v.binding_id]);
29
+ const fail = (error: string, status = 409) => Response.json({ ok: false, error }, { status });
30
+ const ok = (value: object) => Response.json({ ok: true, ...value });
31
+
32
+ // Recheck stored identities without storing plaintext bearer tokens. The SQL
33
+ // mirrors resolveToken's expiry/revoke/idle rules; membership is checked below.
34
+ function credential(tokenId: string): Credential | null {
35
+ const days = sessionIdleDays();
36
+ return db.get<Credential>(`SELECT t.token_id,t.user_id,t.network_id,t.bound_node_id,t.scope,u.role
37
+ FROM api_tokens t JOIN users u ON u.user_id=t.user_id WHERE t.token_id=?1
38
+ AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>datetime('now'))
39
+ AND NOT (t.scope='user' AND t.network_id IS NULL AND ?2>0
40
+ AND COALESCE(t.last_used_at,t.created_at)<datetime('now',?3))`,
41
+ tokenId, days, `-${Math.round(days * 86400)} seconds`) ?? null;
42
+ }
43
+ function node(nodeId: string): Node | null {
44
+ return db.get<Node>("SELECT node_id,network_id,owner_user_id,alias FROM nodes WHERE node_id=?1", nodeId) ?? null;
45
+ }
46
+ function isHumanCredential(c: Credential | null): c is Credential {
47
+ return !!c && c.network_id === null && c.bound_node_id === null
48
+ && (c.scope === "user" || c.scope === "full");
49
+ }
50
+ function human(tokenId: string, n: Node): boolean {
51
+ const c = credential(tokenId);
52
+ if (!isHumanCredential(c) || !n.network_id) return false;
53
+ const role = getUserNetworkRole(c.user_id, n.network_id);
54
+ return !!role && role !== "viewer" && !isAgentRestricted(c.user_id, n.network_id)
55
+ && (c.user_id === n.owner_user_id || role === "owner" || role === "admin" || c.role === "admin");
56
+ }
57
+ function bound(tokenId: string, n?: Node | null): Node | null {
58
+ const c = credential(tokenId);
59
+ if (!c?.bound_node_id || !c.network_id || c.scope !== "network") return null;
60
+ const target = n ?? node(c.bound_node_id);
61
+ if (!target || c.bound_node_id !== target.node_id || c.network_id !== target.network_id
62
+ || !getUserNetworkRole(c.user_id, c.network_id) || isAgentRestricted(c.user_id, c.network_id)) return null;
63
+ const caller = resolveNodeCaller(tokenId);
64
+ return caller.ok && caller.kind === "node" && caller.nodeId === target.node_id
65
+ && caller.networkId === target.network_id ? target : null;
66
+ }
67
+ async function body(req: Request): Promise<Record<string, unknown> | null> {
68
+ const reader = req.body?.getReader();
69
+ if (!reader) return null;
70
+ const chunks: Uint8Array[] = []; let size = 0;
71
+ try {
72
+ for (;;) {
73
+ const part = await reader.read(); if (part.done) break;
74
+ size += part.value.byteLength;
75
+ if (size > 8192) { await reader.cancel(); return null; }
76
+ chunks.push(part.value);
77
+ }
78
+ const v: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
79
+ return object(v) ? v : null;
80
+ } catch { return null; }
81
+ }
82
+ function state(value: unknown, e: Entry): boolean {
83
+ return object(value) && keys(value, ["session_id", "binding_id", "supported", "active", "can_request_background", "observed_at"])
84
+ && same(value as Identity, e) && typeof value.supported === "boolean" && typeof value.active === "boolean"
85
+ && typeof value.can_request_background === "boolean" && typeof value.observed_at === "number"
86
+ && Number.isFinite(value.observed_at) && value.observed_at > 0
87
+ && (!value.can_request_background || (value.supported && value.active));
88
+ }
89
+ function validResult(v: unknown, e: Entry): boolean {
90
+ if (e.operation === "state") return state(v, e);
91
+ return object(v) && keys(v, ["outcome", "state"])
92
+ && (v.outcome === "request_processed" || v.outcome === "already_idle") && state(v.state, e)
93
+ && (v.outcome !== "already_idle" || (object(v.state)
94
+ && v.state.active === false && v.state.can_request_background === false));
95
+ }
96
+
97
+ /** One finite, process-local relay per Hub boot. Restart loses requests (404),
98
+ * never replays them. Multi-worker deployments require process affinity.
99
+ * A claim authorizes one delivery, not proof that any tool moved to background.
100
+ */
101
+ export function createNodeSessionControlRelay() {
102
+ const bridges = new Map<string, Bridge>();
103
+ const entries = new Map<string, Entry>();
104
+ const key = (n: Node) => JSON.stringify([n.network_id, n.node_id]);
105
+ function close(e: Entry, error: string) {
106
+ e.status = e.status === "in_progress" ? "unknown" : "failed";
107
+ e.error = e.status === "unknown" ? "session_background_outcome_unknown" : error;
108
+ }
109
+ function refresh(e: Entry, now: number) {
110
+ if (e.status !== "pending" && e.status !== "in_progress") return;
111
+ if (now >= e.expires_at) {
112
+ e.status = e.status === "in_progress" ? "unknown" : "expired";
113
+ e.error = e.status === "unknown" ? "session_background_outcome_unknown" : "session_control_request_expired";
114
+ return;
115
+ }
116
+ const n = node(e.nodeId), b = n && bridges.get(key(n));
117
+ if (!n || n.network_id !== e.networkId || !human(e.requester, n) || !bound(e.tokenId, n)) {
118
+ close(e, "session_control_unavailable");
119
+ } else if (!b || b.expires <= now || b.tokenId !== e.tokenId || !same(b, e)) {
120
+ close(e, "session_control_changed");
121
+ }
122
+ }
123
+ function sweep(now: number) {
124
+ for (const [k, e] of entries) {
125
+ refresh(e, now);
126
+ if (now > e.expires_at + 60_000) entries.delete(k);
127
+ }
128
+ }
129
+ return async function handle(req: Request, url: URL): Promise<Response | null> {
130
+ if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`)) return null;
131
+ try {
132
+ // Header-only, persisted credential authority; no master/dev-open fallback.
133
+ const raw = req.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? "";
134
+ const auth = resolveToken(raw);
135
+ if (!auth?.tokenId) return fail("session_control_unavailable", 401);
136
+ const tokenId = auth.tokenId;
137
+ const c = credential(tokenId);
138
+ if (!c) return fail("session_control_unavailable", 401);
139
+ const isHuman = isHumanCredential(c);
140
+ const isNode = c.scope === "network" && !!c.network_id;
141
+ const suffix = url.pathname.slice(ROOT.length);
142
+ if (req.method === "GET" && /^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}$/.test(suffix)) {
143
+ const e = entries.get(suffix.slice(1));
144
+ // Unknown/restarted Hub and unauthorized reads reveal no request data.
145
+ if (!isHuman) return fail("session_control_unavailable", 403);
146
+ if (!e) return fail("session_control_unavailable", 404);
147
+ const n = node(e.nodeId);
148
+ if (!n || url.searchParams.get("network_id") !== e.networkId || n.network_id !== e.networkId || !human(tokenId, n)) {
149
+ return fail("session_control_unavailable", 403);
150
+ }
151
+ refresh(e, Date.now());
152
+ return ok({ request_id: e.request_id, status: e.status,
153
+ ...(e.result === undefined ? {} : { result: e.result }), ...(e.error ? { error: e.error } : {}) });
154
+ }
155
+ if (req.method !== "POST") return fail("session_control_request_invalid", 405);
156
+ const v = await body(req);
157
+ if (!v) return fail("session_control_request_invalid", 400);
158
+ const now = Date.now();
159
+ if (suffix === "/bridge") {
160
+ if (!isNode) return fail("session_control_unavailable", 403);
161
+ if (!keys(v, ["session_id", "binding_id", "expected"]) || !id(v.session_id) || !id(v.binding_id)
162
+ || !object(v.expected) || !keys(v.expected, ["node_id", "network_id"])
163
+ || !id(v.expected.node_id) || !id(v.expected.network_id)) return fail("session_control_request_invalid", 400);
164
+ // Body reading is complete. This synchronous section rechecks authority
165
+ // and expected identity BEFORE sweep, retirement, or registration writes.
166
+ // expected is an assertion, never a source for binding or proof fields.
167
+ const c = credential(tokenId), n = bound(tokenId);
168
+ if (!c || !n) return fail("session_control_unavailable", 403);
169
+ if (c.bound_node_id !== n.node_id || c.network_id !== n.network_id
170
+ || v.expected.node_id !== c.bound_node_id || v.expected.network_id !== c.network_id) {
171
+ return fail("session_control_changed");
172
+ }
173
+ const registration = { session_id: v.session_id, binding_id: v.binding_id };
174
+ const k = key(n), b = bridges.get(k), g = generation(tokenId, registration);
175
+ if (b?.retired.has(g)) return fail("session_control_changed");
176
+ const replacing = b && (b.tokenId !== tokenId || !same(b, registration));
177
+ if ((replacing && b.retired.size >= HISTORY) || (!b && bridges.size >= CAP)) {
178
+ return fail("session_control_request_limit", 429);
179
+ }
180
+ sweep(now);
181
+ if (replacing) {
182
+ b.retired.add(generation(b.tokenId, b));
183
+ for (const e of entries.values()) if (e.nodeId === n.node_id && e.networkId === n.network_id
184
+ && (e.status === "pending" || e.status === "in_progress")) close(e, "session_control_changed");
185
+ }
186
+ bridges.set(k, { ...registration, tokenId, expires: now + LEASE, retired: b?.retired ?? new Set() });
187
+ return ok({ expires_at: now + LEASE,
188
+ identity: { node_id: c.bound_node_id, network_id: c.network_id, token_id: c.token_id } });
189
+ }
190
+ sweep(now);
191
+ if (!suffix) {
192
+ if (!isHuman) return fail("session_control_unavailable", 403);
193
+ if (!keys(v, ["network_id", "node_id", "operation", "expected"]) || !id(v.network_id) || !id(v.node_id)
194
+ || (v.operation !== "state" && v.operation !== "background")
195
+ || (v.expected !== undefined && !identity(v.expected)) || (v.operation === "background" && !identity(v.expected))) {
196
+ return fail("session_control_request_invalid", 400);
197
+ }
198
+ const n = node(v.node_id);
199
+ if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
200
+ const b = bridges.get(key(n));
201
+ if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_unavailable");
202
+ if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
203
+ if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
204
+ && e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
205
+ return fail("session_control_request_limit", 429);
206
+ }
207
+ const request_id = `${v.operation === "background" ? "bg" : "sc"}_${now}_${randomBytes(18).toString("base64url")}`;
208
+ entries.set(request_id, { session_id: b.session_id, binding_id: b.binding_id, request_id,
209
+ operation: v.operation, networkId: n.network_id, nodeId: n.node_id, tokenId: b.tokenId,
210
+ requester: tokenId, expires_at: now + TTL, status: "pending" });
211
+ pushEvent(n.alias, { type: "session_control", request_id }, n.network_id);
212
+ return ok({ request_id, status: "pending", expires_at: now + TTL });
213
+ }
214
+ if (!isNode) return fail("session_control_unavailable", 403);
215
+ const n = bound(tokenId);
216
+ if (!n) return fail("session_control_unavailable", 403);
217
+ const k = key(n), b = bridges.get(k);
218
+ if (!b || b.expires <= now || b.tokenId !== tokenId) return fail("session_control_changed");
219
+ if (suffix === "/pull") {
220
+ if (!identity(v)) return fail("session_control_request_invalid", 400);
221
+ if (!same(b, v)) return fail("session_control_changed");
222
+ const e = [...entries.values()].find(e => e.nodeId === n.node_id && e.networkId === n.network_id && e.status === "pending");
223
+ if (!e) return ok({ request: null });
224
+ refresh(e, now);
225
+ if (e.status !== "pending") return ok({ request: null });
226
+ // No await between authority recheck, claim, and response. Never requeue.
227
+ e.status = "in_progress";
228
+ return ok({ request: { request_id: e.request_id, operation: e.operation,
229
+ session_id: e.session_id, binding_id: e.binding_id, expires_at: e.expires_at } });
230
+ }
231
+ if (/^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}\/ack$/.test(suffix)) {
232
+ if (!keys(v, ["session_id", "binding_id", "status", "result", "error"]) || !id(v.session_id) || !id(v.binding_id)
233
+ || (v.status !== "done" && v.status !== "failed")) return fail("session_control_request_invalid", 400);
234
+ const e = entries.get(suffix.split("/")[1]);
235
+ if (!e) return fail("session_control_unavailable", 404);
236
+ if (e.nodeId !== n.node_id || e.networkId !== n.network_id || e.tokenId !== tokenId || !same(e, v as Identity)
237
+ || !same(b, e)) return fail("session_control_changed");
238
+ refresh(e, now);
239
+ if (e.status !== "in_progress") return fail("session_control_request_expired");
240
+ if (v.status === "done" && validResult(v.result, e) && v.error === undefined) {
241
+ e.status = "done"; e.result = v.result;
242
+ } else if (v.status === "failed" && typeof v.error === "string" && ERRORS.has(v.error) && v.result === undefined) {
243
+ e.status = "failed"; e.error = v.error;
244
+ } else {
245
+ // Invalid/untrusted runtime replies never become success or leak raw errors.
246
+ e.status = "unknown"; e.error = "session_control_invalid_response";
247
+ return fail(e.error, 400);
248
+ }
249
+ return ok({ request_id: e.request_id, status: e.status });
250
+ }
251
+ return fail("session_control_request_invalid", 404);
252
+ } catch { return fail("session_control_unavailable", 503); }
253
+ };
254
+ }
package/src/server.ts CHANGED
@@ -92,12 +92,14 @@ import { requestNodeSecrets, getScopedSecrets } from "./scoped-secrets.js";
92
92
  import { readEnvTransportLog } from "./env-transport-gate.js";
93
93
  import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
94
94
  import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
95
+ import { createNodeSessionControlRelay } from "./node-session-control.js";
95
96
  import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
96
97
  import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
97
98
  import { handleSideThreadHttpRequest } from "./side-thread-http.js";
98
99
  import { createProductionSideThreadTransport } from "./side-thread-production.js";
99
100
  import { parseHubTimestamp } from "./hub-timestamp";
100
101
  import { logTaskRejection } from "./task-rejection-log.js";
102
+ import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
101
103
 
102
104
  const PORT = resolvePort(process.env.PORT);
103
105
  const HOST = process.env.HOST || "127.0.0.1";
@@ -1027,6 +1029,7 @@ function patrolDeliveredStaleTasks(): void {
1027
1029
  // Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||`
1028
1030
  // would swallow a legitimate `0`. Same for hostname.
1029
1031
  export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
1032
+ const nodeSessionControl = createNodeSessionControlRelay();
1030
1033
  return Bun.serve({
1031
1034
  port: opts?.port ?? PORT,
1032
1035
  hostname: opts?.hostname ?? HOST,
@@ -1485,10 +1488,148 @@ return Bun.serve({
1485
1488
  const avatar = validateAvatarUrl((body as { avatar_url: unknown }).avatar_url);
1486
1489
  if (!avatar.ok) return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: avatar.reason }, { status: 400 }));
1487
1490
  // No supplied target ID/alias; user identity comes exclusively from auth.
1488
- db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [avatar.value, resolved.user.user_id]);
1491
+ // 清空或换成另一张(池/外链)时,旧托管行必须同一事务撤销:否则「清空后旧 URL 仍可读」。
1492
+ db.transaction(() => {
1493
+ db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [avatar.value, resolved.user.user_id]);
1494
+ const keepsUploaded = typeof avatar.value === "string" && /^\/api\/users\/[^/]+\/avatar\/[A-Za-z0-9_-]{1,128}\.png$/.test(avatar.value);
1495
+ if (!keepsUploaded) db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [resolved.user.user_id]);
1496
+ });
1489
1497
  return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: avatar.value }));
1490
1498
  }
1491
1499
 
1500
+ // ── 账号头像上传(Hub 托管,非普通附件)──
1501
+ //
1502
+ // 契约(通信Mac马 交付单):用户令牌 + multipart 仅一个 `file` 字段;PNG、
1503
+ // 最长边 ≤512、≤8 MiB;服务端独立复核后原子覆盖本人头像;URL 由 Hub 发。
1504
+ // 读取走 GET /api/users/:id/avatar/:version.png,按**人类资料可见性**判读
1505
+ // (本人或共享网络的合法成员),不借用 /api/files 的附件 ACL。
1506
+ if (url.pathname === "/api/auth/me/avatar/upload" && req.method === "POST") {
1507
+ // pre-drain 拒绝统一关闭连接:keepalive 上残留的 multipart 未读字节会把
1508
+ // 下一个请求读成垃圾(与 /api/upload 的 earlyReject 同一理由)。
1509
+ const earlyReject = (res: Response): Response => {
1510
+ const wrapped = withCors(req, res);
1511
+ wrapped.headers.set("Connection", "close");
1512
+ return wrapped;
1513
+ };
1514
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1515
+ if (!token) return earlyReject(Response.json({ ok: false, error: "token required" }, { status: 401 }));
1516
+ const resolved = resolveToken(token);
1517
+ if (!resolved) return earlyReject(Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1518
+ // 节点令牌代表机器,不是这个人:头像只能由本人用用户令牌写。
1519
+ if (isNodeCredential(resolved)) return earlyReject(userTokenRequired(req));
1520
+
1521
+ // 解析 multipart **之前**先限流:否则一个被限流的调用方仍能让 Hub 读满 8 MiB。
1522
+ const authCtx = resolveRequestAuth(req);
1523
+ const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
1524
+ const rate = sharedUploadRateLimiter.check(rateKey);
1525
+ if (!rate.allowed) {
1526
+ const headers: Record<string, string> = {
1527
+ "X-RateLimit-Limit": String(60),
1528
+ "X-RateLimit-Remaining": "0",
1529
+ "X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
1530
+ };
1531
+ if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
1532
+ return earlyReject(new Response(
1533
+ JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
1534
+ { status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } },
1535
+ ));
1536
+ }
1537
+
1538
+ if (!isMultipart(req.headers.get("content-type"))) {
1539
+ return earlyReject(Response.json({ ok: false, error: "multipart_required" }, { status: 415 }));
1540
+ }
1541
+ const declared = Number(req.headers.get("content-length") ?? NaN);
1542
+ // 多留 1 MiB 给 multipart 信封;真正的二进制上限在解析后按 MAX_AVATAR_BYTES 再判一次。
1543
+ if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES + 1024 * 1024) {
1544
+ return earlyReject(Response.json({ ok: false, error: "too_large" }, { status: 413 }));
1545
+ }
1546
+ let form: FormData;
1547
+ try { form = await req.formData(); } catch {
1548
+ return withCors(req, Response.json({ ok: false, error: "invalid_multipart" }, { status: 400 }));
1549
+ }
1550
+ const fields = [...form.keys()];
1551
+ const file = form.get(AVATAR_FIELD);
1552
+ if (fields.length !== 1 || fields[0] !== AVATAR_FIELD || !(file instanceof File)) {
1553
+ return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected exactly one `file` part" }, { status: 400 }));
1554
+ }
1555
+ if (file.size > MAX_AVATAR_BYTES) return withCors(req, Response.json({ ok: false, error: "too_large" }, { status: 413 }));
1556
+ const bytes = new Uint8Array(await file.arrayBuffer());
1557
+ const check = inspectAvatarImage(bytes);
1558
+ if (!check.ok) return withCors(req, Response.json({ ok: false, error: "invalid_image", reason: check.reason }, { status: 400 }));
1559
+ const version = crypto.randomUUID().replace(/-/g, "");
1560
+ const url = avatarUrl(resolved.user.user_id, version);
1561
+ // 头像表与用户资料必须在同一事务里落地:否则会出现「上传成功但资料页还是旧头像」,
1562
+ // 或反过来点了清空却仍能读到旧字节。
1563
+ db.transaction(() => {
1564
+ db.run(
1565
+ `INSERT INTO user_avatar_uploads (user_id, version, png, bytes, width, height, updated_at)
1566
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))
1567
+ ON CONFLICT(user_id) DO UPDATE SET
1568
+ version = excluded.version, png = excluded.png, bytes = excluded.bytes,
1569
+ width = excluded.width, height = excluded.height, updated_at = excluded.updated_at`,
1570
+ [resolved.user.user_id, version, bytes, bytes.byteLength, check.width, check.height],
1571
+ );
1572
+ db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [url, resolved.user.user_id]);
1573
+ });
1574
+ // 回读用户资料行,确认读取面能看到这张新头像(而不是只信内存里的变量)。
1575
+ const persisted = db.get<{ avatar_url: string | null }>("SELECT avatar_url FROM users WHERE user_id = ?1", [resolved.user.user_id]);
1576
+ if (persisted?.avatar_url !== url) {
1577
+ return withCors(req, Response.json({ ok: false, error: "avatar_persist_failed" }, { status: 500 }));
1578
+ }
1579
+ return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: url }));
1580
+ }
1581
+
1582
+ // GET /api/users/:user_id/avatar/:version.png —— Bearer 用户令牌鉴权。
1583
+ // 可见性 = 本人,或与调用者共享至少一个网络的合法人类成员;节点令牌不是「人」。
1584
+ // 不可见与不存在都回 404:不让人用状态码探测别人的账号是否存在。
1585
+ const userAvatarMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/avatar\/([^/]+)\.png$/);
1586
+ if (userAvatarMatch && req.method === "GET") {
1587
+ const [, rawUserId, rawVersion] = userAvatarMatch;
1588
+ let targetUserId: string;
1589
+ try { targetUserId = decodeURIComponent(rawUserId); } catch {
1590
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1591
+ }
1592
+ if (!isValidAvatarVersion(rawVersion)) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1593
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1594
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1595
+ const resolved = resolveToken(token);
1596
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1597
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1598
+ const callerId = resolved.user.user_id;
1599
+ if (callerId !== targetUserId) {
1600
+ // live membership:网络必须仍然存在(删除网络只留下 network_members 残行时不算共享)。
1601
+ const shared = db.get<{ ok: number }>(
1602
+ `SELECT 1 AS ok FROM network_members me
1603
+ JOIN networks n ON n.network_id = me.network_id
1604
+ JOIN network_members them ON them.network_id = me.network_id
1605
+ WHERE me.user_id = ?1 AND them.user_id = ?2 LIMIT 1`,
1606
+ [callerId, targetUserId],
1607
+ );
1608
+ if (!shared) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1609
+ }
1610
+ // 只服务「当前」头像:清空/替换后旧 version 立即失效,即使行还没被删。
1611
+ const current = db.get<{ avatar_url: string | null }>(
1612
+ "SELECT avatar_url FROM users WHERE user_id = ?1", [targetUserId],
1613
+ );
1614
+ if (!current || current.avatar_url !== avatarUrl(targetUserId, rawVersion)) {
1615
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1616
+ }
1617
+ const row = db.get<{ png: Uint8Array }>(
1618
+ "SELECT png FROM user_avatar_uploads WHERE user_id = ?1 AND version = ?2",
1619
+ [targetUserId, rawVersion],
1620
+ );
1621
+ if (!row) return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1622
+ return withCors(req, new Response(Buffer.from(row.png), {
1623
+ status: 200,
1624
+ headers: {
1625
+ "Content-Type": "image/png",
1626
+ // 头像随账号更新,不能让中间层缓存住旧的那张;也不做重定向。
1627
+ "Cache-Control": "private, no-store",
1628
+ "X-Content-Type-Options": "nosniff",
1629
+ },
1630
+ }));
1631
+ }
1632
+
1492
1633
  if (url.pathname === "/api/auth/me" && req.method === "PUT") {
1493
1634
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1494
1635
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
@@ -2655,6 +2796,9 @@ return Bun.serve({
2655
2796
  });
2656
2797
  if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
2657
2798
 
2799
+ const sessionControlResponse = await nodeSessionControl(req, url);
2800
+ if (sessionControlResponse) return withCors(req, sessionControlResponse);
2801
+
2658
2802
  // Hub-owned scheduled tasks. Dashboard and mobile are management
2659
2803
  // clients only; each occurrence is dispatched as an ordinary task by the
2660
2804
  // scheduler started in startHub(). Node tokens are rejected in the
@@ -207,7 +207,11 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
207
207
  // tools. Measured on this combined tree: all 95 / 85,069 B; node 62 / 63,281 B
208
208
  // unchanged (protocol tools stay off the node list); user 62 / 59,409 B.
209
209
  // Ceilings keep about 400 B and nothing else.
210
- const CEILINGS = { node: 63_700, user: 59_810, all: 85_500 } as const;
210
+ // #1894(2026-10-23):新增一个 `departments` MCP 工具(list/create/update/delete/
211
+ // assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
212
+ // 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
213
+ // 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
214
+ const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
211
215
  test("node / user / all", async () => {
212
216
  const node = await listTools(T.node_normal);
213
217
  const user = await listTools(T.owner);
@@ -59,6 +59,8 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
59
59
  upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
60
60
  list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
61
61
  requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
62
+ // #1894 —— 组织架构:人和 Agent 都能用(Agent 用它归部门,人用它看树)。
63
+ departments: "both",
62
64
  requirements_update: "both", requirements_checklist_toggle: "both", requirements_upsert_by_external_ref: "both",
63
65
  requirements_events: "both", requirements_comment: "both", projects_list: "both",
64
66
  };
@@ -65,3 +65,30 @@ describe("#1756 tools/list", () => {
65
65
  } finally { await close(); }
66
66
  }, 20_000);
67
67
  });
68
+
69
+ // #1075 / #2662 —— `node_secrets_capable` 是**节点自述**的一格,运行时把它放在
70
+ // `config_snapshot` 里(agent-node/src/cli.ts:1894)。而 zod 对象默认**丢掉未声明
71
+ // 的键**:只要 `config_snapshot` 的形状里没有这一条,节点上报得再好,到 Hub 也被
72
+ // 静默剥掉 → 客户端「保存密钥」永远是灰的,日志里一个字都不会说。
73
+ //
74
+ // 所以钉在这里:`report_status.config_snapshot` 的形状里**必须有**这一格。
75
+ // 剥掉它的方式有很多种(重写对象、加 .strict()、换 z.record),这条断言对它们都红。
76
+ describe("#1075 node_secrets_capable survives report_status", () => {
77
+ test("config_snapshot's shape declares node_secrets_capable", async () => {
78
+ const { server, close } = await connect();
79
+ try {
80
+ const reg: Record<string, any> = (server as any)._registeredTools;
81
+ const shape = reg["report_status"]?.inputSchema?.shape ?? {};
82
+ const snapshotField = shape["config_snapshot"];
83
+ expect(snapshotField).toBeTruthy();
84
+ // config_snapshot 是 ZodOptional 包着 ZodObject:.unwrap() 之后才有 shape。
85
+ const unwrapped = (snapshotField as any)?.unwrap ? (snapshotField as any).unwrap() : snapshotField;
86
+ const inner = (unwrapped as any)?.shape ?? {};
87
+ expect(Object.keys(inner)).toContain("node_secrets_capable");
88
+ // 同族的另一格(daemon 级能力)也在这份形状里,顺手一起钉住。
89
+ expect(Object.keys(inner)).toContain("daemon_capabilities");
90
+ } finally {
91
+ await close();
92
+ }
93
+ });
94
+ });
package/src/tools.ts CHANGED
@@ -107,6 +107,7 @@ import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue
107
107
  import { parseHubTimestamp } from "./hub-timestamp";
108
108
  import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
109
109
  import { listTaskLateReplies } from "./task-late-replies.js";
110
+ import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setAgentDepartment, updateDepartment } from "./departments.js";
110
111
 
111
112
  function ts(): string {
112
113
  return new Date().toTimeString().slice(0, 8);
@@ -425,6 +426,96 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
425
426
  content: [{ type: "text" as const, text: JSON.stringify(value) }],
426
427
  });
427
428
 
429
+ // ── #1894 组织架构:Agent 用 API 就能建部门、把 Agent 归进去(admin 2026-10-23:
430
+ // 不要靠人手动点)。写权限 = 网络 owner/admin(实测 `canWrite` 对普通成员也是 true,
431
+ // 口径太宽,所以这里显式判角色);读权限 = 能读这个网络的人。
432
+ // 归属写的是 nodes.department_id,显式归属,绝不按负责人推断。
433
+ const deptReply = (value: Record<string, unknown>) => ({
434
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
435
+ });
436
+ const canManageDepartments = (netId: string | null): boolean => {
437
+ if (!netId) return false;
438
+ if (!enforceUserId) return canWrite(netId); // legacy open mode
439
+ const role = getUserNetworkRole(enforceUserId, netId);
440
+ return role === "owner" || role === "admin";
441
+ };
442
+ const deptFail = (result: any) =>
443
+ deptReply({ ok: false, error: result?.error ?? "department_failed", status: result?.status ?? 400, ...(result?.children || result?.members || result?.agents ? { children: result.children, members: result.members, agents: result.agents } : {}) });
444
+
445
+ server.tool(
446
+ "departments",
447
+ "Agent org structure: list / create / update / delete / assign_agent. Writes need network owner or admin; assign_agent sets nodes.department_id.",
448
+ {
449
+ action: z.enum(["list", "create", "update", "delete", "assign_agent"]),
450
+ department_id: z.string().max(64).nullable().optional(),
451
+ name: z.string().min(1).max(80).optional(),
452
+ parent_id: z.string().max(64).nullable().optional(),
453
+ node_id: z.string().max(200).optional(),
454
+ alias: z.string().max(200).optional(),
455
+ network_id: z.string().max(200).optional(),
456
+ },
457
+ async ({ action, department_id, name, parent_id, node_id, alias, network_id: clientNetId }) => {
458
+ const netId = getNetworkId(clientNetId);
459
+ if (!netId) return deptReply({ ok: false, error: "network_required" });
460
+ if (action === "list") {
461
+ if (!canManageDepartments(netId) && !canWriteHuman(netId)) return writeDeniedReply(netId, "read") as any;
462
+ const listed = listDepartments(netId);
463
+ const agents = db.all<{ node_id: string; alias: string; department_id: string | null }>(
464
+ "SELECT node_id, alias, department_id FROM nodes WHERE network_id = ?1 ORDER BY alias", netId,
465
+ );
466
+ const counts = new Map<string, number>();
467
+ let unassigned = 0;
468
+ for (const a of agents) {
469
+ if (a.department_id) counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
470
+ else unassigned += 1;
471
+ }
472
+ return deptReply({
473
+ ok: true, network_id: netId, unassigned_agents: unassigned, agents,
474
+ departments: listed.departments.map((d) => ({ ...d, agent_count: counts.get(d.id) ?? 0 })),
475
+ });
476
+ }
477
+ if (!canManageDepartments(netId)) return writeDeniedReply(netId, "write") as any;
478
+ const actor = { user_id: enforceUserId || "node", username: callerAlias || "agent" };
479
+ if (action === "create") {
480
+ if (!name) return deptReply({ ok: false, error: "invalid_department_name" });
481
+ const result = createDepartment(netId, enforceUserId || callerAlias || "mcp", {
482
+ name, ...(parent_id ? { parent_id } : {}), ...(department_id ? { id: department_id } : {}),
483
+ }) as any;
484
+ if (result?.ok === false) return deptFail(result);
485
+ auditDepartment(actor, "department_create", netId, String(result.department?.id ?? ""));
486
+ return deptReply({ ok: true, department: result.department });
487
+ }
488
+ if (action === "update") {
489
+ if (!department_id) return deptReply({ ok: false, error: "department_required" });
490
+ const body: Record<string, unknown> = {};
491
+ if (name !== undefined) body.name = name;
492
+ if (parent_id !== undefined) body.parent_id = parent_id;
493
+ const result = updateDepartment(netId, department_id, body) as any;
494
+ if (result?.ok === false) return deptFail(result);
495
+ auditDepartment(actor, "department_update", netId, `id=${department_id}`);
496
+ return deptReply({ ok: true, department: result.department });
497
+ }
498
+ if (action === "delete") {
499
+ if (!department_id) return deptReply({ ok: false, error: "department_required" });
500
+ const result = deleteDepartment(netId, department_id) as any;
501
+ if (result?.ok === false) return deptFail(result);
502
+ auditDepartment(actor, "department_delete", netId, `id=${department_id}`);
503
+ return deptReply({ ok: true, deleted: department_id });
504
+ }
505
+ let target = node_id ?? "";
506
+ if (!target && alias) {
507
+ const row = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", netId, resolveCanonicalAlias(netId, alias).alias);
508
+ if (!row) return deptReply({ ok: false, error: "agent_not_found" });
509
+ target = row.node_id;
510
+ }
511
+ if (!target) return deptReply({ ok: false, error: "agent_required" });
512
+ const result = setAgentDepartment(netId, target, department_id === undefined ? null : department_id) as any;
513
+ if (result?.ok === false) return deptFail(result);
514
+ auditDepartment(actor, "department_assign_agent", netId, `${target} -> ${department_id ?? "null"}`);
515
+ return deptReply({ ok: true, node_id: result.node_id, department_id: result.department_id });
516
+ },
517
+ );
518
+
428
519
  // SkillHub is a network registry, not a public filesystem. Content is an
429
520
  // immutable SKILL.md snapshot; identity comes only from the authenticated
430
521
  // MCP principal (never from request fields).