@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/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 current PostgreSQL adapter opens a fresh process/connection for each
4174
- // statement, so its transaction() cannot provide the occurrence claim +
4175
- // task creation atomicity this scheduler requires. Refuse before opening a
4176
- // listening socket instead of silently running an unsafe scheduler.
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 the currently non-atomic PostgreSQL adapter", () => {
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
- // PgAdapter transactions are currently documented non-atomic. Advertising
51
- // a durable command outbox on it would be false, so fail closed until a
52
- // real PostgreSQL race gate and single-connection transaction land.
53
- if (db.dialect !== "sqlite") throw new Error("SideThread command transport requires atomic SQLite transactions");
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
- // PgAdapter.transaction currently opens a fresh subprocess/connection
1308
- // per statement. The all-or-nothing batch promise cannot be made there;
1309
- // refuse evidence rather than publish a partially stamped batch.
1310
- if (db.dialect !== "sqlite") {
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(),