@sleep2agi/commhub-server 0.9.0-preview.142 → 0.9.0-preview.144
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 +1 -1
- package/src/agent-department-http.test.ts +16 -0
- package/src/requirements-people.ts +9 -2
- package/src/rest-alias-network-fallback-http.test.ts +123 -0
- package/src/rest-alias-network-fallback.test.ts +60 -0
- package/src/rest-alias-network-fallback.ts +65 -0
- package/src/server.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.144",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -145,4 +145,20 @@ describe("Agent 显式归属部门", () => {
|
|
|
145
145
|
const agents2 = [...(dir2.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir2.body.agents_without_owner ?? [])];
|
|
146
146
|
expect(agents2.find((a: any) => a.node_id === NODE_A)?.department_id ?? null).toBeNull();
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
// #1976 —— Agent 归属与人类部门是两个独立的结构。通讯录顶层要说明「这个 Hub 支持显式
|
|
150
|
+
// Agent 部门」,客户端据此把「行里没有 department_id」读成「没有显式归属」,
|
|
151
|
+
// 而不是从「负责人在哪个部门」推断出来。
|
|
152
|
+
test("通讯录声明支持显式 Agent 部门(与人类部门相互独立)", async () => {
|
|
153
|
+
const dir = await send(U.owner.token, "GET", `/api/requirements/people/directory?network_id=${NET}`);
|
|
154
|
+
expect(dir.status).toBe(200);
|
|
155
|
+
expect(dir.body.agent_department_supported).toBe(true);
|
|
156
|
+
// 行形状不变:没有显式归属的行**不带** department_id 这一格(旧客户端形状不变)。
|
|
157
|
+
const agents = [...(dir.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir.body.agents_without_owner ?? [])];
|
|
158
|
+
for (const a of agents) {
|
|
159
|
+
// 显式归属 ⇒ 带这一格且非空;没有显式归属 ⇒ **不带这一格**(旧客户端形状不变)。
|
|
160
|
+
if (a.department_id) expect(typeof a.department_id).toBe("string");
|
|
161
|
+
else expect(Object.prototype.hasOwnProperty.call(a, "department_id")).toBe(false);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
148
164
|
});
|
|
@@ -72,8 +72,9 @@ export function peopleDirectory(networkId: string, hidden: HiddenNode, query: {
|
|
|
72
72
|
const byOwner = new Map<string, DirectoryAgent[]>();
|
|
73
73
|
const unowned: DirectoryAgent[] = [];
|
|
74
74
|
for (const n of nodes) {
|
|
75
|
-
// 只在**显式**归属时带这一格:没设 =
|
|
76
|
-
//
|
|
75
|
+
// 只在**显式**归属时带这一格:没设 = 没有显式归属(顶层 agent_department_supported 告诉
|
|
76
|
+
// 客户端这一点)。行形状保持不变 —— 旧客户端拿到的行还是老样子(多一个键会让严格要求
|
|
77
|
+
// 形状的测试/客户端意外),只是新客户端不再把「负责人在哪个部门」当成 Agent 的部门。
|
|
77
78
|
const agent = { node_id: n.node_id, alias: n.alias || n.node_name, ...(n.department_id ? { department_id: n.department_id } : {}) };
|
|
78
79
|
if (n.owner_user_id && memberIds.has(n.owner_user_id)) {
|
|
79
80
|
const list = byOwner.get(n.owner_user_id) ?? [];
|
|
@@ -104,6 +105,12 @@ export function peopleDirectory(networkId: string, hidden: HiddenNode, query: {
|
|
|
104
105
|
network_id: networkId,
|
|
105
106
|
total: people.length,
|
|
106
107
|
people: page,
|
|
108
|
+
// #1976 —— 「Agent 归属」与「人类部门」是两个独立的结构。
|
|
109
|
+
// Agent 放哪个部门,只由**显式**设置(nodes.department_id)决定;**不再**从「负责人在哪个部门」推断。
|
|
110
|
+
// 这一格告诉客户端「这个 Hub 支持显式 Agent 部门」:客户端据此把「没有 department_id 这一行」
|
|
111
|
+
// 读成「没有显式归属 ⇒ 未归属」,而不是「按负责人部门推断」。
|
|
112
|
+
// 老 Hub 没有这一格 ⇒ 客户端保留旧的推断口径,行为不变。
|
|
113
|
+
agent_department_supported: true,
|
|
107
114
|
...(query.offset === 0 ? { agents_without_owner: unowned.filter(a => hit(a.alias)) } : {}),
|
|
108
115
|
has_more: hasMore,
|
|
109
116
|
next_offset: hasMore ? query.offset + query.limit : null,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// #1974 —— 多网络账号给「另一个网络里的 Agent」发消息,hub 侧也要能发出去。
|
|
2
|
+
//
|
|
3
|
+
// 起因(admin 2026-10-21 截图 + hub 拒绝日志):
|
|
4
|
+
// [15:55:54] tang → /api/task → tang02: REJECTED 404 alias_not_found (net=net_ac567b4ab74e)
|
|
5
|
+
//
|
|
6
|
+
// 客户端的 Agent 列表是跨网络的(GET /api/status?light=1 不带 network_id ⇒ 返回调用者所属的
|
|
7
|
+
// 全部网络),发送却带着「登录时选的那一个网络」。这里用真实 HTTP + 临时库复现那个形状:
|
|
8
|
+
// * admin 拥有 NET_A,里面有 Agent `cross-agent`;
|
|
9
|
+
// * tang 注册 ⇒ 自己的个人网络 NET_B(owner),同时是 NET_A 的 admin 成员(agent_access 'all');
|
|
10
|
+
// * tang 用 NET_B 发给 `cross-agent` ⇒ 修之前 404 alias_not_found,修之后送达 NET_A。
|
|
11
|
+
//
|
|
12
|
+
// 三条红线各有断言:同名在两个网络都有 ⇒ 不猜(仍 404);没授权的网络 ⇒ 不去;不存在的 alias ⇒ 404。
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { addNetworkMember, register } from "./auth.js";
|
|
19
|
+
import { db } from "./db.js";
|
|
20
|
+
|
|
21
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-1974-alias-net-"));
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
const PW = "AliasNetPassw0rd!x";
|
|
25
|
+
let NET_A = "";
|
|
26
|
+
let NET_B = "";
|
|
27
|
+
let tangToken = "";
|
|
28
|
+
let tangUserId = "";
|
|
29
|
+
|
|
30
|
+
const stamp = Date.now();
|
|
31
|
+
|
|
32
|
+
let sessionSeq = 0;
|
|
33
|
+
function insertSession(alias: string, networkId: string, nodeId: string | null = null) {
|
|
34
|
+
sessionSeq += 1;
|
|
35
|
+
db.run(
|
|
36
|
+
`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at, last_seen_at)
|
|
37
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'), datetime('now'))`,
|
|
38
|
+
[`resume_${alias}_${stamp}_${sessionSeq}`, alias, nodeId, networkId],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function postTask(token: string, body: any) {
|
|
43
|
+
const res = await fetch(`${BASE}/api/task`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let json: any = text;
|
|
50
|
+
try { json = JSON.parse(text); } catch { /* keep text */ }
|
|
51
|
+
return { status: res.status, json };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
56
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
57
|
+
process.env.HOST = "127.0.0.1";
|
|
58
|
+
|
|
59
|
+
const admin = register(`aliasnet_admin_${stamp}`, PW, undefined, "Admin");
|
|
60
|
+
expect(admin.ok).toBe(true);
|
|
61
|
+
NET_A = admin.network_id!;
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.user!.user_id]);
|
|
63
|
+
|
|
64
|
+
// admin 名下的 Agent:只在 NET_A 里。
|
|
65
|
+
insertSession("cross-agent", NET_A, `node_cross_${stamp}`);
|
|
66
|
+
|
|
67
|
+
// tang:自己的个人网络 NET_B(owner)+ NET_A 的 admin 成员(两个网络 agent_access 都是 'all',
|
|
68
|
+
// 这正是现网 tang 的形状:owner 个人网络 + admin 团队网络)。
|
|
69
|
+
const tang = register(`aliasnet_tang_${stamp}`, PW, undefined, "Tang");
|
|
70
|
+
expect(tang.ok).toBe(true);
|
|
71
|
+
NET_B = tang.network_id!;
|
|
72
|
+
tangToken = tang.token!;
|
|
73
|
+
tangUserId = tang.user!.user_id;
|
|
74
|
+
expect(addNetworkMember(NET_A, tangUserId, "admin", admin.user!.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
75
|
+
|
|
76
|
+
// 同名 alias 在**请求的网络之外**的两个网络里都有 ⇒ 不猜。
|
|
77
|
+
// 需要第三个网络:另一个用户建自己的网络,tang 也是它的成员。
|
|
78
|
+
const third = register(`aliasnet_third_${stamp}`, PW, undefined, "Third");
|
|
79
|
+
expect(third.ok).toBe(true);
|
|
80
|
+
const NET_C = third.network_id!;
|
|
81
|
+
expect(addNetworkMember(NET_C, tangUserId, "member", third.user!.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
82
|
+
insertSession("twin-agent", NET_A, `node_twin_a_${stamp}`);
|
|
83
|
+
insertSession("twin-agent", NET_C, `node_twin_c_${stamp}`);
|
|
84
|
+
|
|
85
|
+
const mod: any = await import("./server.js");
|
|
86
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
87
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
try { hub?.stop?.(); } catch { /* ignore */ }
|
|
92
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("POST /api/task across the caller's own networks (#1974)", () => {
|
|
96
|
+
test("an alias in the caller's other network is delivered there, not 404", async () => {
|
|
97
|
+
const res = await postTask(tangToken, { alias: "cross-agent", task: "hi", network_id: NET_B });
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(res.json.ok).toBe(true);
|
|
100
|
+
// 落在 NET_A(那个 alias 真正所在的网络),不是请求里带的 NET_B。
|
|
101
|
+
expect(res.json.actual_to?.network_id ?? res.json.actual_to?.networkId).toBe(NET_A);
|
|
102
|
+
const row = db.get<{ network_id: string }>("SELECT network_id FROM tasks WHERE task_id = ?1", [res.json.task_id]);
|
|
103
|
+
expect(row?.network_id).toBe(NET_A);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the same alias in two of the caller's networks is not guessed", async () => {
|
|
107
|
+
const res = await postTask(tangToken, { alias: "twin-agent", task: "hi", network_id: NET_B });
|
|
108
|
+
expect(res.status).toBe(404);
|
|
109
|
+
expect(res.json.error).toBe("alias_not_found");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("an alias nobody has is still 404", async () => {
|
|
113
|
+
const res = await postTask(tangToken, { alias: "nowhere-agent", task: "hi", network_id: NET_B });
|
|
114
|
+
expect(res.status).toBe(404);
|
|
115
|
+
expect(res.json.error).toBe("alias_not_found");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the requested network still wins when it has the alias", async () => {
|
|
119
|
+
const res = await postTask(tangToken, { alias: "cross-agent", task: "hi2", network_id: NET_A });
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
expect(res.json.actual_to?.network_id ?? res.json.actual_to?.networkId).toBe(NET_A);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// #1974 —— alias 不在请求的网络里、但在调用者另一个网络里(且唯一)时,发到那儿去。
|
|
2
|
+
//
|
|
3
|
+
// 三条红线各有���一条断言:只找调用者已加入的网络、同名不猜、没权限不换。
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { pickAliasNetwork } from "./rest-alias-network-fallback.js";
|
|
6
|
+
|
|
7
|
+
const base = {
|
|
8
|
+
alias: "tang02",
|
|
9
|
+
requestedNetworkId: "net_own",
|
|
10
|
+
memberNetworkIds: ["net_own", "net_team"],
|
|
11
|
+
networksWithAlias: ["net_team"],
|
|
12
|
+
canWrite: () => true,
|
|
13
|
+
canMessage: () => true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("pickAliasNetwork (#1974)", () => {
|
|
17
|
+
test("the alias lives in exactly one other member network ⇒ use it", () => {
|
|
18
|
+
expect(pickAliasNetwork({ ...base })).toBe("net_team");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the alias is in the requested network too ⇒ do not switch", () => {
|
|
22
|
+
expect(pickAliasNetwork({ ...base, networksWithAlias: ["net_own", "net_team"] })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the same alias in two other networks ⇒ do not guess", () => {
|
|
26
|
+
expect(pickAliasNetwork({
|
|
27
|
+
...base,
|
|
28
|
+
memberNetworkIds: ["net_own", "net_a", "net_b"],
|
|
29
|
+
networksWithAlias: ["net_a", "net_b"],
|
|
30
|
+
})).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a network the caller does not belong to is never used", () => {
|
|
34
|
+
expect(pickAliasNetwork({
|
|
35
|
+
...base,
|
|
36
|
+
memberNetworkIds: ["net_own"],
|
|
37
|
+
networksWithAlias: ["net_team"],
|
|
38
|
+
})).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("no write access in that network ⇒ keep the 404", () => {
|
|
42
|
+
expect(pickAliasNetwork({ ...base, canWrite: () => false })).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("no message permission in that network ⇒ keep the 404", () => {
|
|
46
|
+
expect(pickAliasNetwork({ ...base, canMessage: () => false })).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the alias is nowhere ⇒ keep the 404", () => {
|
|
50
|
+
expect(pickAliasNetwork({ ...base, networksWithAlias: [] })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no membership at all ⇒ keep the 404", () => {
|
|
54
|
+
expect(pickAliasNetwork({ ...base, memberNetworkIds: [] })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty alias is never sent anywhere", () => {
|
|
58
|
+
expect(pickAliasNetwork({ ...base, alias: " " })).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// #1974 —— 「alias 不在请求的网络里」时,发到调用者**另一个**网络里那个 alias 所在的网络。
|
|
2
|
+
//
|
|
3
|
+
// 起因(admin 2026-10-21 截图 + hub 拒绝日志):
|
|
4
|
+
// [15:55:54] tang → /api/task → tang02: REJECTED 404 alias_not_found (net=net_ac567b4ab74e)
|
|
5
|
+
//
|
|
6
|
+
// 客户端的 Agent 列表是**跨网络**的:`GET /api/status?light=1` 不带 network_id,
|
|
7
|
+
// hub 就按「调用者属于哪些网络」返回。tang 因此同时看到默认网络(net_399bdf86f528)的
|
|
8
|
+
// tang02/03/04 和自己个人网络(net_ac567b4ab74e)里的 Agent。发送却带着「登录时选定的
|
|
9
|
+
// 那一个网络」—— 多网络账号(自己网络的 owner + 团队网络的 admin)两个网络对它都是
|
|
10
|
+
// agent_access='all',pickDefaultNetworkId 于是选中了自己的个人网络 ⇒ 给团队网络的
|
|
11
|
+
// Agent 发消息一律 404 alias_not_found,客户端只显示「没有找到这个 Agent」+「未送达」。
|
|
12
|
+
//
|
|
13
|
+
// 客户端侧已有修法(用 alias 所在网络发),但那只在**新客户端**里生效;已经在用的旧客户端
|
|
14
|
+
// 仍然发不出去。这里补 Hub 侧:alias 唯一地存在于调用者的另一个网络、且调用者在那个网络
|
|
15
|
+
// 有写权限 ⇒ 就发到那儿去。旧客户端立刻受益,不用等升级。
|
|
16
|
+
//
|
|
17
|
+
// 三条红线:
|
|
18
|
+
// ① 只在调用者**已加入**的网络里找(不跨租户、不做 alias 探测);
|
|
19
|
+
// ② 同一个 alias 在调用者的多个网络里都存在 ⇒ **不猜**,保持 404;
|
|
20
|
+
// ③ 调用者在目标网络没有写权限(受限成员没被授权这个 Agent)⇒ 保持 404/403。
|
|
21
|
+
//
|
|
22
|
+
// 纯函数:不碰 db、不碰 http,好测。
|
|
23
|
+
|
|
24
|
+
export interface AliasNetworkFallbackInput {
|
|
25
|
+
/** 请求里带的 alias(已按请求网络 canonical 化)。 */
|
|
26
|
+
alias: string;
|
|
27
|
+
/** 请求带/推出的那个网络。 */
|
|
28
|
+
requestedNetworkId: string | null;
|
|
29
|
+
/** 调用者所属的全部网络。 */
|
|
30
|
+
memberNetworkIds: readonly string[];
|
|
31
|
+
/** 上面这些网络里,真的存在这个 alias 的网络(去重后)。 */
|
|
32
|
+
networksWithAlias: readonly string[];
|
|
33
|
+
/** 调用者在这个网络能不能写。 */
|
|
34
|
+
canWrite: (networkId: string) => boolean;
|
|
35
|
+
/** 调用者能不能给这个 alias 发消息(受限成员的 Agent 授权)。 */
|
|
36
|
+
canMessage: (networkId: string) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 该发到哪个网络?返回 null = 保持原来的 404。
|
|
41
|
+
*
|
|
42
|
+
* 只在「请求的网络里没有这个 alias」时才会换网络;请求网络里有 ⇒ 传 requestedNetworkId
|
|
43
|
+
* 之外一律不动(调用方只在 not_found 时才问这个函数)。
|
|
44
|
+
*/
|
|
45
|
+
export function pickAliasNetwork(input: AliasNetworkFallbackInput): string | null {
|
|
46
|
+
const alias = (input.alias ?? '').trim();
|
|
47
|
+
if (!alias) return null;
|
|
48
|
+
const requested = (input.requestedNetworkId ?? '').trim();
|
|
49
|
+
const members = new Set((input.memberNetworkIds ?? []).map(n => (n ?? '').trim()).filter(Boolean));
|
|
50
|
+
if (members.size === 0) return null;
|
|
51
|
+
|
|
52
|
+
// 请求的网络里本来就有这个 alias ⇒ 不换网络(它就在那儿,不用猜)。
|
|
53
|
+
const withAlias = [...new Set((input.networksWithAlias ?? []).map(n => (n ?? '').trim()).filter(Boolean))];
|
|
54
|
+
if (requested && withAlias.includes(requested)) return null;
|
|
55
|
+
// 候选 = 调用者所属、且真的有这个 alias 的网络;请求网络本身不算。
|
|
56
|
+
const candidates = withAlias.filter(n => n !== requested && members.has(n));
|
|
57
|
+
if (candidates.length === 0) return null;
|
|
58
|
+
// 多个网络都有同名 alias ⇒ 不猜(alias 探测的防线)。
|
|
59
|
+
if (candidates.length > 1) return null;
|
|
60
|
+
|
|
61
|
+
const only = candidates[0];
|
|
62
|
+
if (!input.canWrite(only)) return null;
|
|
63
|
+
if (!input.canMessage(only)) return null;
|
|
64
|
+
return only;
|
|
65
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js
|
|
|
32
32
|
import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
33
33
|
import { flagOrphanTasks } from "./task-orphan.js";
|
|
34
34
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
35
|
+
import { pickAliasNetwork } from "./rest-alias-network-fallback.js";
|
|
35
36
|
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
36
37
|
import { listAdoptionCandidates, snapshotReportsAdoptionDiscovery } from "./adoption-candidates.js";
|
|
37
38
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
@@ -3779,6 +3780,37 @@ return Bun.serve({
|
|
|
3779
3780
|
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
3780
3781
|
}, { status: 400 })));
|
|
3781
3782
|
}
|
|
3783
|
+
// #1974 —— alias 不在请求的网络里、但在调用者**另一个**网络里(且唯一)⇒ 发到那儿去。
|
|
3784
|
+
// 客户端的 Agent 列表是跨网络的(/api/status?light=1 不带 network_id),发送却带着
|
|
3785
|
+
// 「登录时选的那一个网络」;多网络账号因此给别的网络里的 Agent 发消息一律 404
|
|
3786
|
+
// (tang → tang02,2026-10-21)。旧客户端不改也能立刻受益。
|
|
3787
|
+
// 找不到 / 多个网络同名 / 没权限 ⇒ 不动,保持原来的 404。
|
|
3788
|
+
if (restAuth && taskNetId) {
|
|
3789
|
+
const canonicalAlias = resolveCanonicalAlias(taskNetId, body.alias).alias;
|
|
3790
|
+
const memberNets = getUserNetworkIds(restAuth.userId);
|
|
3791
|
+
const netsWithAlias = memberNets.filter(n => !!db.get(
|
|
3792
|
+
"SELECT 1 AS ok FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
3793
|
+
[canonicalAlias, n],
|
|
3794
|
+
));
|
|
3795
|
+
const alt = pickAliasNetwork({
|
|
3796
|
+
alias: canonicalAlias,
|
|
3797
|
+
requestedNetworkId: taskNetId,
|
|
3798
|
+
memberNetworkIds: memberNets,
|
|
3799
|
+
networksWithAlias: netsWithAlias,
|
|
3800
|
+
canWrite: (net) => canRestWriteNetwork(restAuth, net, isAdmin),
|
|
3801
|
+
canMessage: (net) => {
|
|
3802
|
+
if (isAgentRestricted(restAuth.userId, net)) {
|
|
3803
|
+
const row = db.get<{ node_id: string | null }>(
|
|
3804
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
3805
|
+
[canonicalAlias, net],
|
|
3806
|
+
);
|
|
3807
|
+
return canMessageAgent(restAuth.userId, net, { alias: canonicalAlias, nodeId: row?.node_id ?? null });
|
|
3808
|
+
}
|
|
3809
|
+
return true;
|
|
3810
|
+
},
|
|
3811
|
+
});
|
|
3812
|
+
if (alt) taskNetId = alt;
|
|
3813
|
+
}
|
|
3782
3814
|
// 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
|
|
3783
3815
|
// 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
|
|
3784
3816
|
const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
|