@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.71
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 +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/agent-access.ts +196 -2
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/auth.ts +76 -1
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +182 -60
- package/src/db.ts +39 -2
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +43 -9
- package/src/scheduled-tasks-http.test.ts +71 -20
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +117 -11
- package/src/side-thread-command-transport.test.ts +2 -2
- package/src/side-thread-command-transport.ts +4 -4
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +6 -5
package/src/server.ts
CHANGED
|
@@ -16,11 +16,11 @@ import { pendingInboxCount } from "./inbox-count.js";
|
|
|
16
16
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
17
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
18
18
|
import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
19
|
-
import { canMessageAgent, isAgentRestricted, listAgentGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias } from "./agent-access.js";
|
|
19
|
+
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
20
20
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
21
21
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
22
22
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
23
|
-
import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
23
|
+
import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
24
24
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
25
25
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
26
26
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -42,8 +42,8 @@ import {
|
|
|
42
42
|
validateAttachments,
|
|
43
43
|
validateIndexEntry,
|
|
44
44
|
} from "./uploads.js";
|
|
45
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
46
|
-
import { dirname as pathDirname } from "path";
|
|
45
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
46
|
+
import { dirname as pathDirname, join as pathJoin } from "path";
|
|
47
47
|
import { startRetentionSweeper } from "./retention.js";
|
|
48
48
|
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
49
49
|
import {
|
|
@@ -274,6 +274,22 @@ function explainExpiredSession(req: Request, res: Response | undefined): Respons
|
|
|
274
274
|
));
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// 上传文件的归属网络记在磁盘索引 <uploadsRoot>/.index/<file_id>.json 的 network_id 里,不在库里。
|
|
278
|
+
// 管理员删空网络前要数它:目录不存在就是 0;目录读不了就抛(不能把「数不了」当成「没有」)。
|
|
279
|
+
function countNetworkUploads(networkId: string): number {
|
|
280
|
+
const dir = pathJoin(getUploadsRoot(), ".index");
|
|
281
|
+
let names: string[];
|
|
282
|
+
try { names = readdirSync(dir); } catch (e: any) { if (e?.code === "ENOENT") return 0; throw e; }
|
|
283
|
+
let count = 0;
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
if (!name.endsWith(".json")) continue;
|
|
286
|
+
try {
|
|
287
|
+
if (JSON.parse(readFileSync(pathJoin(dir, name), "utf8"))?.network_id === networkId) count++;
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
return count;
|
|
291
|
+
}
|
|
292
|
+
|
|
277
293
|
function userTokenRequired(req: Request): Response {
|
|
278
294
|
return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
|
|
279
295
|
}
|
|
@@ -1454,6 +1470,71 @@ return Bun.serve({
|
|
|
1454
1470
|
}
|
|
1455
1471
|
}
|
|
1456
1472
|
|
|
1473
|
+
// ── Agent 分组(RFC-038 §8):owner/admin/Hub 管理员管理;组授权动态生效 ──
|
|
1474
|
+
// GET/POST /api/networks/:id/agent-groups · PATCH/DELETE …/:gid · PUT …/:gid/members {node_ids}
|
|
1475
|
+
const groupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-groups(?:\/([^/]+)(\/members)?)?$/);
|
|
1476
|
+
if (groupsMatch) {
|
|
1477
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1478
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1479
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1480
|
+
// 组是人的管理对象,节点令牌不能碰。
|
|
1481
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1482
|
+
const netId = decodeURIComponent(groupsMatch[1]);
|
|
1483
|
+
const groupId = groupsMatch[2] ? decodeURIComponent(groupsMatch[2]) : null;
|
|
1484
|
+
const membersPath = !!groupsMatch[3];
|
|
1485
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1486
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1487
|
+
// 受限成员(以及普通成员)看不到组列表:他们只需要看到最终能访问的 Agent。
|
|
1488
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1489
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1490
|
+
}
|
|
1491
|
+
const actor = resolved.user;
|
|
1492
|
+
const readBody = async (): Promise<any | null> => {
|
|
1493
|
+
try { const b = await req.json(); return b && typeof b === "object" && !Array.isArray(b) ? b : null; } catch { return null; }
|
|
1494
|
+
};
|
|
1495
|
+
const kick = (userIds: string[]) => { for (const uid of userIds) closeUserStreamsInNetwork(netId, uid); };
|
|
1496
|
+
if (!groupId && req.method === "GET") {
|
|
1497
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, groups: listAgentGroups(netId) }));
|
|
1498
|
+
}
|
|
1499
|
+
if (!groupId && req.method === "POST") {
|
|
1500
|
+
const body = await readBody();
|
|
1501
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1502
|
+
const r = createAgentGroup({ networkId: netId, name: body.name, description: body.description, nodeIds: body.node_ids, actorUserId: actor.user_id });
|
|
1503
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1504
|
+
logAudit(actor.user_id, actor.username, "agent_group_created", "agent_group", r.group.group_id,
|
|
1505
|
+
JSON.stringify({ name: r.group.name, node_ids: r.group.node_ids }).slice(0, 4000), undefined, netId);
|
|
1506
|
+
return withCors(req, Response.json({ ok: true, group: r.group }));
|
|
1507
|
+
}
|
|
1508
|
+
if (groupId && !membersPath && req.method === "PATCH") {
|
|
1509
|
+
const body = await readBody();
|
|
1510
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1511
|
+
const r = updateAgentGroup({ networkId: netId, groupId, name: body.name, description: body.description });
|
|
1512
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1513
|
+
logAudit(actor.user_id, actor.username, "agent_group_renamed", "agent_group", groupId, JSON.stringify({ name: r.group.name }), undefined, netId);
|
|
1514
|
+
return withCors(req, Response.json({ ok: true, group: r.group }));
|
|
1515
|
+
}
|
|
1516
|
+
if (groupId && membersPath && req.method === "PUT") {
|
|
1517
|
+
const body = await readBody();
|
|
1518
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1519
|
+
const r = replaceAgentGroupMembers({ networkId: netId, groupId, nodeIds: body.node_ids, actorUserId: actor.user_id });
|
|
1520
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1521
|
+
logAudit(actor.user_id, actor.username, "agent_group_members_changed", "agent_group", groupId,
|
|
1522
|
+
JSON.stringify({ added: r.added, removed: r.removed }).slice(0, 4000), undefined, netId);
|
|
1523
|
+
// 组成员变了:这个组上有授权的成员按新权限重连(观察流按连接时的权限过滤)。
|
|
1524
|
+
if (r.added.length || r.removed.length) kick(usersGrantedGroup(groupId));
|
|
1525
|
+
return withCors(req, Response.json({ ok: true, group: r.group, added: r.added, removed: r.removed }));
|
|
1526
|
+
}
|
|
1527
|
+
if (groupId && !membersPath && req.method === "DELETE") {
|
|
1528
|
+
const r = deleteAgentGroup(netId, groupId);
|
|
1529
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
|
|
1530
|
+
logAudit(actor.user_id, actor.username, "agent_group_deleted", "agent_group", groupId,
|
|
1531
|
+
JSON.stringify({ affected_user_count: r.affected_user_ids.length }), undefined, netId);
|
|
1532
|
+
kick(r.affected_user_ids);
|
|
1533
|
+
return withCors(req, Response.json({ ok: true, affected_user_ids: r.affected_user_ids }));
|
|
1534
|
+
}
|
|
1535
|
+
return withCors(req, Response.json({ ok: false, error: "method not allowed" }, { status: 405 }));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1457
1538
|
// ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
|
|
1458
1539
|
// GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
|
|
1459
1540
|
// PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
|
|
@@ -1482,6 +1563,7 @@ return Bun.serve({
|
|
|
1482
1563
|
agent_access: getAgentAccessMode(netId, targetUid),
|
|
1483
1564
|
restricted: isAgentRestricted(targetUid, netId),
|
|
1484
1565
|
grants: listAgentGrants(netId, targetUid),
|
|
1566
|
+
group_grants: listGroupGrants(netId, targetUid),
|
|
1485
1567
|
}));
|
|
1486
1568
|
}
|
|
1487
1569
|
// 网络 admin 不能改 owner / 其他 admin 的授权(对他们本来也不生效,但别让 admin 去碰平级)。
|
|
@@ -1491,7 +1573,7 @@ return Bun.serve({
|
|
|
1491
1573
|
let body: any;
|
|
1492
1574
|
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1493
1575
|
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1494
|
-
if (body.grants === undefined && body.node_ids === undefined && body.agent_access === undefined) {
|
|
1576
|
+
if (body.grants === undefined && body.node_ids === undefined && body.agent_access === undefined && body.group_grants === undefined) {
|
|
1495
1577
|
return withCors(req, Response.json({ ok: false, error: "grants or agent_access required" }, { status: 400 }));
|
|
1496
1578
|
}
|
|
1497
1579
|
const result = replaceAgentGrants({
|
|
@@ -1499,17 +1581,18 @@ return Bun.serve({
|
|
|
1499
1581
|
userId: targetUid,
|
|
1500
1582
|
grants: body.grants ?? body.node_ids,
|
|
1501
1583
|
agentAccess: body.agent_access,
|
|
1584
|
+
groupGrants: body.group_grants,
|
|
1502
1585
|
actorUserId: resolved.user.user_id,
|
|
1503
1586
|
});
|
|
1504
1587
|
if (!result.ok) {
|
|
1505
1588
|
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1506
1589
|
}
|
|
1507
1590
|
logAudit(resolved.user.user_id, resolved.user.username, "member_agent_grants_changed", "network", netId,
|
|
1508
|
-
JSON.stringify({ user_id: targetUid, agent_access: result.agent_access, grants: result.grants.map((g) => ({ node_id: g.node_id, alias: g.alias, can_message: g.can_message })) }).slice(0, 4000),
|
|
1591
|
+
JSON.stringify({ user_id: targetUid, agent_access: result.agent_access, grants: result.grants.map((g) => ({ node_id: g.node_id, alias: g.alias, can_message: g.can_message })), group_grants: result.group_grants.map((g) => ({ group_id: g.group_id, can_message: g.can_message })) }).slice(0, 4000),
|
|
1509
1592
|
undefined, netId);
|
|
1510
1593
|
// 已连着的观察流 / 用户流按旧权限鉴权过,断开让它按新权限重连。
|
|
1511
1594
|
closeUserStreamsInNetwork(netId, targetUid);
|
|
1512
|
-
return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants }));
|
|
1595
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants, group_grants: result.group_grants }));
|
|
1513
1596
|
}
|
|
1514
1597
|
|
|
1515
1598
|
// ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
|
|
@@ -1689,6 +1772,19 @@ return Bun.serve({
|
|
|
1689
1772
|
const resolved = resolveToken(token);
|
|
1690
1773
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1691
1774
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1775
|
+
// Hub 管理员删别人的网络:同一个资源、同一个动词,只是换一条判据 —— 只允许空网络
|
|
1776
|
+
// (计数见 auth.ts adminDeleteEmptyNetwork)。owner 自己删仍走下面原来的路径,行为不变。
|
|
1777
|
+
const owner = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", netDetailMatch[1]);
|
|
1778
|
+
if (owner && owner.owner_id !== resolved.user.user_id && isHubAdminCredential(resolved)) {
|
|
1779
|
+
const adminResult = adminDeleteEmptyNetwork(netDetailMatch[1], { files: countNetworkUploads(netDetailMatch[1]) });
|
|
1780
|
+
if (!adminResult.ok) {
|
|
1781
|
+
return withCors(req, Response.json({ ok: false, error: adminResult.error, counts: adminResult.counts }, { status: adminResult.status }));
|
|
1782
|
+
}
|
|
1783
|
+
logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1],
|
|
1784
|
+
JSON.stringify({ admin_override: true, owner_id: adminResult.owner_id, network_name: adminResult.network_name, cleaned: adminResult.cleaned }),
|
|
1785
|
+
getClientIP(req, server), netDetailMatch[1]);
|
|
1786
|
+
return withCors(req, Response.json({ ok: true, admin_override: true, cleaned: adminResult.cleaned }));
|
|
1787
|
+
}
|
|
1692
1788
|
const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
|
|
1693
1789
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
|
|
1694
1790
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1999,6 +2095,16 @@ return Bun.serve({
|
|
|
1999
2095
|
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
2000
2096
|
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
2001
2097
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
2098
|
+
// `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
|
|
2099
|
+
// chat info / node detail read the full projection for ONE agent; unfiltered that is every
|
|
2100
|
+
// session in the network (533 KB, 94 KB gzip on the production network) per open and per
|
|
2101
|
+
// 10 s poll. Older hubs ignore the parameter and return every row — the app still picks its
|
|
2102
|
+
// row by alias, so old app × new hub and new app × old hub both keep working.
|
|
2103
|
+
const aliasFilter = url.searchParams.get("alias")?.trim();
|
|
2104
|
+
if (aliasFilter) {
|
|
2105
|
+
params.push(aliasFilter);
|
|
2106
|
+
sql += ` AND alias = ?${params.length}`;
|
|
2107
|
+
}
|
|
2002
2108
|
sql += " ORDER BY updated_at DESC";
|
|
2003
2109
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
2004
2110
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
@@ -4170,10 +4276,10 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
4170
4276
|
`For an additional throwaway instance use bootServer({ port: 0 }).`
|
|
4171
4277
|
);
|
|
4172
4278
|
}
|
|
4173
|
-
// The
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
4279
|
+
// The scheduler needs the occurrence claim + task creation to be one
|
|
4280
|
+
// transaction. On an adapter whose transaction() is not atomic, refuse
|
|
4281
|
+
// before opening a listening socket instead of running an unsafe
|
|
4282
|
+
// scheduler.
|
|
4177
4283
|
assertScheduledTaskBackendSupported();
|
|
4178
4284
|
const server = bootServer(opts);
|
|
4179
4285
|
|
|
@@ -178,8 +178,8 @@ describe("Hub durable SideThread command outbox", () => {
|
|
|
178
178
|
a.close(); b.close();
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
test("fails closed on
|
|
182
|
-
expect(() => new SideThreadCommandStore({ dialect: "postgres" } as any)).toThrow(/atomic SQLite/);
|
|
181
|
+
test("fails closed on an adapter without atomic transactions", () => {
|
|
182
|
+
expect(() => new SideThreadCommandStore({ dialect: "postgres", atomicTransactions: false } as any)).toThrow(/atomic SQLite/);
|
|
183
183
|
});
|
|
184
184
|
test("allows exactly one durable terminal applier", () => {
|
|
185
185
|
const f = fixture(); const close = f.port.subscribe(async () => {});
|
|
@@ -47,10 +47,10 @@ export function installSideThreadCommandSchema(db: DbAdapter): void {
|
|
|
47
47
|
|
|
48
48
|
export class SideThreadCommandStore {
|
|
49
49
|
constructor(readonly db: DbAdapter, private readonly now = Date.now) {
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
if (db.
|
|
50
|
+
// A durable command outbox needs atomic transactions. Advertising one on
|
|
51
|
+
// an adapter without them would be false, so fail closed there
|
|
52
|
+
// (dialect alone does not decide it; see DbAdapter.transactionalFeaturesRefusal).
|
|
53
|
+
if (db.transactionalFeaturesRefusal !== null) throw new Error(`SideThread command transport requires atomic SQLite transactions${db.transactionalFeaturesRefusal ? `: ${db.transactionalFeaturesRefusal}` : ""}`);
|
|
54
54
|
installSideThreadCommandSchema(db);
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// GET /api/status?alias=<name> — one agent's rows instead of the whole network.
|
|
2
|
+
// The app's chat info / node detail read the FULL projection for a single agent; unfiltered that
|
|
3
|
+
// was every session (533 KB raw / 94 KB gzip on the production network) per open and per 10 s poll.
|
|
4
|
+
// Old hubs ignore the parameter, so the app keeps picking its row by alias; this suite pins that
|
|
5
|
+
// the filter narrows, never widens (network scope still applies), and that no/blank alias is the
|
|
6
|
+
// old response.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { register } from "./auth.js";
|
|
13
|
+
import { db } from "./db.js";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-status-alias-"));
|
|
16
|
+
let BASE = "";
|
|
17
|
+
let hub: any = null;
|
|
18
|
+
const PW = "StatusAliasPassw0rd!";
|
|
19
|
+
let aToken = "", aId = "", NET_A = "";
|
|
20
|
+
let bToken = "", bId = "", NET_B = "";
|
|
21
|
+
|
|
22
|
+
async function get(token: string, path: string) {
|
|
23
|
+
const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
24
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
25
|
+
}
|
|
26
|
+
function seed(alias: string, node: string, net: string, owner: string, minutesAgo: number) {
|
|
27
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, net, owner]);
|
|
28
|
+
db.run(
|
|
29
|
+
`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, ?5, datetime('now', ?6), datetime('now'))`,
|
|
30
|
+
[`r_${node}`, alias, node, net, `task of ${alias}`, `-${minutesAgo} minutes`],
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const aliases = (rows: any[]) => rows.map(r => r.alias);
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
37
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
38
|
+
process.env.HOST = "127.0.0.1";
|
|
39
|
+
const a = register(`sa_owner_a_${Date.now()}`, PW);
|
|
40
|
+
aToken = a.token!; aId = a.user!.user_id; NET_A = a.network_id!;
|
|
41
|
+
const b = register(`sa_owner_b_${Date.now()}`, PW);
|
|
42
|
+
bToken = b.token!; bId = b.user!.user_id; NET_B = b.network_id!;
|
|
43
|
+
seed("sa-one", "node_sa_one", NET_A, aId, 1);
|
|
44
|
+
seed("sa-two", "node_sa_two", NET_A, aId, 2);
|
|
45
|
+
seed("sa-three", "node_sa_three", NET_A, aId, 3);
|
|
46
|
+
// Same alias in someone else's network: the filter must not reach it.
|
|
47
|
+
seed("sa-two", "node_sa_two_b", NET_B, bId, 0);
|
|
48
|
+
const mod: any = await import("./server.js");
|
|
49
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
50
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
51
|
+
}, 30_000);
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
try { hub?.stop?.(true); } catch {}
|
|
55
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("/api/status?alias=", () => {
|
|
59
|
+
test("no alias: every session in scope, newest first (unchanged)", async () => {
|
|
60
|
+
const r = await get(aToken, `/api/status?network_id=${NET_A}`);
|
|
61
|
+
expect(r.status).toBe(200);
|
|
62
|
+
expect(aliases(r.body.sessions)).toEqual(["sa-one", "sa-two", "sa-three"]);
|
|
63
|
+
expect(r.body.summary.total).toBe(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("alias narrows the full projection to that agent", async () => {
|
|
67
|
+
const r = await get(aToken, `/api/status?network_id=${NET_A}&alias=sa-two`);
|
|
68
|
+
expect(r.status).toBe(200);
|
|
69
|
+
expect(aliases(r.body.sessions)).toEqual(["sa-two"]);
|
|
70
|
+
expect(r.body.sessions[0].node_id).toBe("node_sa_two");
|
|
71
|
+
// still the full projection (capability bits / host block), not the light one
|
|
72
|
+
expect(typeof r.body.sessions[0].rules_file_capable).toBe("boolean");
|
|
73
|
+
expect(r.body.sessions[0].host).toBeDefined();
|
|
74
|
+
expect(r.body.summary.total).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("alias narrows the light projection too", async () => {
|
|
78
|
+
const r = await get(aToken, `/api/status?light=1&network_id=${NET_A}&alias=sa-three`);
|
|
79
|
+
expect(aliases(r.body.sessions)).toEqual(["sa-three"]);
|
|
80
|
+
expect(r.body.sessions[0].host).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the filter never widens scope: same alias in another network stays invisible", async () => {
|
|
84
|
+
const scoped = await get(aToken, `/api/status?network_id=${NET_A}&alias=sa-two`);
|
|
85
|
+
expect(scoped.body.sessions.map((s: any) => s.node_id)).toEqual(["node_sa_two"]);
|
|
86
|
+
// Unscoped: whatever the caller's scope already shows (the first registered user is an admin
|
|
87
|
+
// here, so that is both networks) — the filter only picks rows out of it, never adds any.
|
|
88
|
+
const all = await get(aToken, `/api/status`);
|
|
89
|
+
const unscoped = await get(aToken, `/api/status?alias=sa-two`);
|
|
90
|
+
expect(unscoped.body.sessions.map((s: any) => s.node_id)).toEqual(all.body.sessions.filter((s: any) => s.alias === "sa-two").map((s: any) => s.node_id));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a non-admin member only ever gets their own network's row for a shared alias", async () => {
|
|
94
|
+
const r = await get(bToken, `/api/status?alias=sa-two`);
|
|
95
|
+
expect(r.body.sessions.map((s: any) => s.node_id)).toEqual(["node_sa_two_b"]);
|
|
96
|
+
const other = await get(bToken, `/api/status?alias=sa-one`);
|
|
97
|
+
expect(other.body.sessions).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("blank alias is ignored; unknown alias is an empty list, not an error", async () => {
|
|
101
|
+
expect(aliases((await get(aToken, `/api/status?network_id=${NET_A}&alias=%20`)).body.sessions)).toEqual(["sa-one", "sa-two", "sa-three"]);
|
|
102
|
+
const none = await get(aToken, `/api/status?network_id=${NET_A}&alias=nobody`);
|
|
103
|
+
expect(none.status).toBe(200);
|
|
104
|
+
expect(none.body.sessions).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -1304,14 +1304,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1304
1304
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
|
|
1305
1305
|
};
|
|
1306
1306
|
}
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
if (db.
|
|
1307
|
+
// The all-or-nothing batch promise needs a transaction() that really is
|
|
1308
|
+
// one transaction; refuse evidence on any adapter that cannot give that
|
|
1309
|
+
// rather than publish a partially stamped batch.
|
|
1310
|
+
if (db.transactionalFeaturesRefusal !== null) {
|
|
1311
1311
|
return {
|
|
1312
1312
|
content: [{ type: "text" as const, text: JSON.stringify({
|
|
1313
1313
|
ok: false,
|
|
1314
|
-
error: "task_runtime_evidence_backend_unsupported",
|
|
1314
|
+
error: "task_runtime_evidence_backend_unsupported", message: db.transactionalFeaturesRefusal || undefined,
|
|
1315
1315
|
}) }],
|
|
1316
1316
|
};
|
|
1317
1317
|
}
|
|
@@ -5768,6 +5768,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5768
5768
|
priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
|
|
5769
5769
|
column: z.enum(["pool", "doing", "done"]).optional(),
|
|
5770
5770
|
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
|
|
5771
|
+
start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
|
|
5771
5772
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5772
5773
|
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5773
5774
|
project_id: z.string().max(200).nullable().optional(),
|