@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.72

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
@@ -3353,7 +3459,7 @@ return Bun.serve({
3353
3459
  db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
3354
3460
  if (node.alias) {
3355
3461
  db.run(
3356
- "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
3462
+ "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
3357
3463
  [node.alias, node.network_id ?? null]
3358
3464
  );
3359
3465
  }
@@ -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
 
@@ -1,12 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { SQLiteAdapter } from "./db-adapter";
3
+ import { PgAdapter, SQLiteAdapter, resolveDatabaseTarget, type DbAdapter } from "./db-adapter";
4
4
  import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
5
5
  import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
6
6
 
7
7
  function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
8
+ // RFC-039 S4: with COMMHUB_TEST_PG_URL (validated by the adapter guard) the
9
+ // same tests run on PostgreSQL, each fixture on an emptied schema.
10
+ function freshDb(): DbAdapter {
11
+ if (!process.env.COMMHUB_TEST_PG_URL) return new SQLiteAdapter(new Database(":memory:"));
12
+ const target = resolveDatabaseTarget();
13
+ if (target.kind !== "postgres") throw new Error("COMMHUB_TEST_PG_URL did not select PostgreSQL");
14
+ const pg = new PgAdapter(target.url);
15
+ pg.exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
16
+ return pg;
17
+ }
8
18
  function fixture() {
9
- const db = new SQLiteAdapter(new Database(":memory:"));
19
+ const db = freshDb();
10
20
  db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
11
21
  const store = new SideThreadCommandStore(db, () => 123);
12
22
  const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
@@ -178,8 +188,8 @@ describe("Hub durable SideThread command outbox", () => {
178
188
  a.close(); b.close();
179
189
  });
180
190
 
181
- test("fails closed on the currently non-atomic PostgreSQL adapter", () => {
182
- expect(() => new SideThreadCommandStore({ dialect: "postgres" } as any)).toThrow(/atomic SQLite/);
191
+ test("fails closed on an adapter without atomic transactions", () => {
192
+ expect(() => new SideThreadCommandStore({ dialect: "postgres", atomicTransactions: false } as any)).toThrow(/atomic SQLite/);
183
193
  });
184
194
  test("allows exactly one durable terminal applier", () => {
185
195
  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
 
@@ -450,6 +450,10 @@ export function installSideThreadSchema(database: DbAdapter): void {
450
450
  if (!/duplicate column|already exists/i.test(error?.message ?? ""))
451
451
  throw error;
452
452
  }
453
+ // RFC-039 F3: operations are listed in insertion order via SQLite's
454
+ // implicit rowid; PostgreSQL needs a real column for that.
455
+ if (database.dialect === "postgres")
456
+ database.exec("ALTER TABLE side_chat_operations ADD COLUMN IF NOT EXISTS rowid BIGSERIAL");
453
457
  }
454
458
 
455
459
  type SideChatRow = {
@@ -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(),
@@ -5779,12 +5780,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5779
5780
  };
5780
5781
  const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5781
5782
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5783
+ const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
5782
5784
 
5783
5785
  server.tool(
5784
5786
  "requirements_list",
5785
- "List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
5787
+ "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}.",
5786
5788
  {
5787
5789
  network_id: z.string().max(200).optional(),
5790
+ seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
5788
5791
  status: z.enum(["pool", "doing", "done"]).optional(),
5789
5792
  project_id: z.string().max(200).optional(),
5790
5793
  owner: z.string().max(210).optional(),
@@ -5793,11 +5796,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5793
5796
  external_ref: z.string().max(200).optional(),
5794
5797
  include_archived: z.boolean().optional(),
5795
5798
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5796
- top_level: z.boolean().optional(),
5799
+ top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
5797
5800
  },
5798
5801
  async (args) => {
5799
5802
  const q = new URLSearchParams();
5800
- for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5803
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5801
5804
  if (args.include_archived) q.set("include_archived", "1");
5802
5805
  if (args.top_level) q.set("top_level", "1");
5803
5806
  return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
@@ -5806,8 +5809,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5806
5809
 
5807
5810
  server.tool(
5808
5811
  "requirements_get",
5809
- "Get one requirement task by id (with description, checklist, owners, project, external_ref).",
5810
- { id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
5812
+ "Get one requirement task by id or by its short number \"#N\" (with description, checklist, owners, project, external_ref, seq).",
5813
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional() },
5811
5814
  async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
5812
5815
  );
5813
5816
 
@@ -5820,15 +5823,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5820
5823
 
5821
5824
  server.tool(
5822
5825
  "requirements_update",
5823
- "Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5824
- { id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5826
+ "Patch a requirement task (id or \"#N\"); omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5827
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5825
5828
  async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5826
5829
  );
5827
5830
 
5828
5831
  server.tool(
5829
5832
  "requirements_checklist_toggle",
5830
5833
  "Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
5831
- { id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5834
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5832
5835
  async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
5833
5836
  );
5834
5837