@sleep2agi/commhub-server 0.9.0-preview.80 → 0.9.0-preview.82
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/project-viewer-can-http.test.ts +114 -0
- package/src/requirements.ts +6 -2
- package/src/server.ts +17 -5
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/task-access.ts +10 -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.82",
|
|
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",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// GET /api/requirements/projects 的每行 viewer_can.edit(app 任务页审计 L13)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
//
|
|
3
|
+
// 客户端据此只在新建 / 挪卡的项目下拉里列「能编辑」的项目。判据必须与真写卡时一致,所以这里对每个项目
|
|
4
|
+
// 都真建一张卡,断言「viewer_can.edit === 建卡成功」,而不是另抄一份规则来比。
|
|
5
|
+
// admin —— Hub 管理员 + 网络 owner:未归档项目全 true,归档的 false(projectRef 拒归档)
|
|
6
|
+
// alice —— scoped 成员:P1 只看 → false,P2 可改 → true(只列得到这两个)
|
|
7
|
+
// carol —— task_access='all' 的成员:未归档全 true
|
|
8
|
+
// vic —— scoped viewer,P3 只看:false(viewer 本来就不能写卡)
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
|
|
17
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-project-viewer-can-"));
|
|
18
|
+
let BASE = "";
|
|
19
|
+
let hub: any = null;
|
|
20
|
+
const PW = "ProjViewerCanPassw0rd!x";
|
|
21
|
+
let NET = "";
|
|
22
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
23
|
+
const P: Record<string, string> = {};
|
|
24
|
+
|
|
25
|
+
type R = { status: number; body: any };
|
|
26
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
27
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
30
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
31
|
+
});
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let body: any = null;
|
|
34
|
+
try { body = JSON.parse(text); } catch {}
|
|
35
|
+
return { status: res.status, body };
|
|
36
|
+
}
|
|
37
|
+
const projects = async (who: string) => {
|
|
38
|
+
const r = await send(U[who].token, "GET", `/api/requirements/projects?network_id=${NET}`);
|
|
39
|
+
expect(r.status).toBe(200);
|
|
40
|
+
return Object.fromEntries((r.body.projects as any[]).map((p) => [p.name, p]));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
45
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
46
|
+
process.env.HOST = "127.0.0.1";
|
|
47
|
+
const a = register(`pvc_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
48
|
+
expect(a.ok).toBe(true);
|
|
49
|
+
U.admin = { token: a.token!, id: a.user!.user_id };
|
|
50
|
+
NET = a.network_id!;
|
|
51
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.admin.id]);
|
|
52
|
+
const mod: any = await import("./server.js");
|
|
53
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
54
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
55
|
+
|
|
56
|
+
const mk = async (username: string, role: string) => {
|
|
57
|
+
expect((await send(U.admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
58
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
59
|
+
expect(login.status).toBe(200);
|
|
60
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
61
|
+
};
|
|
62
|
+
const stamp = Date.now();
|
|
63
|
+
U.alice = await mk(`pvc_alice_${stamp}`, "member");
|
|
64
|
+
U.carol = await mk(`pvc_carol_${stamp}`, "member");
|
|
65
|
+
U.vic = await mk(`pvc_vic_${stamp}`, "viewer");
|
|
66
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, U.carol.id]);
|
|
67
|
+
|
|
68
|
+
for (const name of ["pvc-P1", "pvc-P2", "pvc-P3", "pvc-P4"]) {
|
|
69
|
+
const r = await send(U.admin.token, "POST", "/api/requirements/projects", { network_id: NET, name });
|
|
70
|
+
expect(r.status).toBe(201);
|
|
71
|
+
P[name] = r.body.project.id;
|
|
72
|
+
}
|
|
73
|
+
expect((await send(U.admin.token, "PATCH", `/api/requirements/projects/${P["pvc-P4"]}?network_id=${NET}`, { archived: true })).status).toBe(200);
|
|
74
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { task_access: "scoped", project_grants: [{ project_id: P["pvc-P1"] }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
|
|
75
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.vic.id}/task-grants`, { task_access: "scoped", project_grants: [P["pvc-P3"]] })).status).toBe(200);
|
|
76
|
+
}, 30_000);
|
|
77
|
+
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
try { hub?.stop?.(true); } catch {}
|
|
80
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("GET /api/requirements/projects 的 viewer_can.edit", () => {
|
|
84
|
+
test("每个调用者的取值", async () => {
|
|
85
|
+
const edit = (rows: Record<string, any>) => Object.fromEntries(Object.entries(rows).map(([n, p]) => [n, p.viewer_can?.edit]));
|
|
86
|
+
expect(edit(await projects("admin"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
|
|
87
|
+
expect(edit(await projects("carol"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
|
|
88
|
+
expect(edit(await projects("alice"))).toEqual({ "pvc-P1": false, "pvc-P2": true });
|
|
89
|
+
expect(edit(await projects("vic"))).toEqual({ "pvc-P3": false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("只加字段:其余键与之前逐字相同", async () => {
|
|
93
|
+
const row = (await projects("alice"))["pvc-P2"];
|
|
94
|
+
expect(Object.keys(row).sort()).toEqual(["archived", "color", "createdAt", "id", "name", "sort", "viewer_can"]);
|
|
95
|
+
expect(Object.keys(row.viewer_can)).toEqual(["edit"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("与真写卡一致:edit === 往这个项目建卡成功", async () => {
|
|
99
|
+
let checked = 0;
|
|
100
|
+
for (const who of ["admin", "carol", "alice", "vic"]) {
|
|
101
|
+
for (const [name, p] of Object.entries(await projects(who))) {
|
|
102
|
+
const r = await send(U[who].token, "POST", "/api/requirements", { network_id: NET, name: `pvc-card-${who}-${name}`, project_id: p.id });
|
|
103
|
+
expect({ who, name, created: r.status === 201 }).toEqual({ who, name, created: p.viewer_can.edit });
|
|
104
|
+
checked++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
expect(checked).toBe(11);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("授权改成可编辑后立即反映", async () => {
|
|
111
|
+
expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { project_grants: [{ project_id: P["pvc-P1"], can_edit: true }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
|
|
112
|
+
expect((await projects("alice"))["pvc-P1"].viewer_can.edit).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-s
|
|
|
9
9
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
10
10
|
import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
|
|
11
11
|
import {
|
|
12
|
-
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
|
|
12
|
+
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
|
|
13
13
|
isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
14
14
|
} from "./task-access.js";
|
|
15
15
|
import { notifyParticipantChange } from "./requirement-notify.js";
|
|
@@ -278,7 +278,11 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
|
|
|
278
278
|
const projectParams: unknown[] = [networkId];
|
|
279
279
|
const projectSql = addProjectVisibilityScope(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1`, projectParams, taskCaller(ctx), networkId);
|
|
280
280
|
const rows = db.all<ProjectRow>(`${projectSql} ORDER BY sort, created_at`, ...projectParams);
|
|
281
|
-
|
|
281
|
+
// viewer_can.edit:调用者能不能把卡建进 / 挪进这个项目 —— 与 POST / PATCH 卡片同一套判据
|
|
282
|
+
// (canWrite + projectRef 拒归档 + canUseProject)。只加字段;客户端据此只列能编辑的项目(app 任务页审计 L13)。
|
|
283
|
+
const writable = canWrite(ctx, networkId);
|
|
284
|
+
const canUse = projectUseResolver(taskCaller(ctx), networkId);
|
|
285
|
+
return Response.json({ ok: true, projects: rows.map((row) => ({ ...projectPublic(row), viewer_can: { edit: writable && !row.archived && canUse(row.project_id) } })) });
|
|
282
286
|
}
|
|
283
287
|
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
284
288
|
// 项目管理(建 / 改名 / 归档 / 删)只给不受任务范围限制的人。
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
2
|
import { maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
3
|
+
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
3
4
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
4
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
6
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -2207,11 +2208,19 @@ return Bun.serve({
|
|
|
2207
2208
|
// take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
|
|
2208
2209
|
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
2209
2210
|
// unaffected.
|
|
2210
|
-
const isLight = url.searchParams.get("light") === "1";
|
|
2211
2211
|
const nodeIdFilter = url.searchParams.get("node_id")?.trim();
|
|
2212
|
+
// agent-node < 2.5.0-preview.93 resolves its own alias every 30 s with the WHOLE network's full projection
|
|
2213
|
+
// (~93 KB gzip on production) and reads only sessions[].node_id / .alias — under a hard 2.5 s abort. Over the
|
|
2214
|
+
// RELAY frp tunnel to a China-side machine (~50 KB/s per connection) the body often isn't done in 2.5 s: the
|
|
2215
|
+
// node aborts, the Hub has already pushed the body into the tunnel and frps drops it (~60% of the tunnel,
|
|
2216
|
+
// 2026-10-01). That caller has an exact signature no other client sends (statusAliasResolverRead): give it
|
|
2217
|
+
// the light projection + node_id (~28 KB gzip), which is all it reads. `?full=1` always gets the full body.
|
|
2218
|
+
const aliasResolverRead = statusAliasResolverRead(url.searchParams, requestToken(req), req.headers.get("accept"));
|
|
2219
|
+
const isLight = url.searchParams.get("light") === "1" || aliasResolverRead;
|
|
2220
|
+
const withNodeId = !!nodeIdFilter || aliasResolverRead;
|
|
2212
2221
|
const params: any[] = [];
|
|
2213
2222
|
let sql = isLight
|
|
2214
|
-
? `SELECT alias, status, agent, task, server, updated_at, network_id${
|
|
2223
|
+
? `SELECT alias, status, agent, task, server, updated_at, network_id${withNodeId ? ", node_id" : ""} FROM sessions WHERE 1=1`
|
|
2215
2224
|
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
2216
2225
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
2217
2226
|
// `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
|
|
@@ -2249,8 +2258,9 @@ return Bun.serve({
|
|
|
2249
2258
|
updated_at: s.updated_at ?? null,
|
|
2250
2259
|
runtime: normalizeRuntime(s.agent),
|
|
2251
2260
|
network_id: s.network_id ?? null,
|
|
2252
|
-
// Only when filtering by node_id, so the unfiltered light response
|
|
2253
|
-
|
|
2261
|
+
// Only when filtering by node_id (or for the old alias resolver), so the unfiltered light response
|
|
2262
|
+
// keeps its exact bytes.
|
|
2263
|
+
...(withNodeId ? { node_id: s.node_id ?? null } : {}),
|
|
2254
2264
|
};
|
|
2255
2265
|
}
|
|
2256
2266
|
const externalSchedules = (() => {
|
|
@@ -2296,7 +2306,9 @@ return Bun.serve({
|
|
|
2296
2306
|
else acc.idle++;
|
|
2297
2307
|
return acc;
|
|
2298
2308
|
}, { idle: 0, working: 0, offline: 0, total: sessions.length });
|
|
2299
|
-
|
|
2309
|
+
const res = Response.json({ ok: true, sessions, summary });
|
|
2310
|
+
if (aliasResolverRead) res.headers.set("X-Status-Projection", "alias-resolver");
|
|
2311
|
+
return withCors(req, res);
|
|
2300
2312
|
}
|
|
2301
2313
|
|
|
2302
2314
|
// ── REST: aggregate agents by physical server ──
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 旧版 agent-node(< 2.5.0-preview.93)找自己别名的全量 /api/status 读法 → light 投影 + node_id。
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-10-01,app「连接较慢」#431):生产上约 2.3 次/秒的全量状态读(~93 KB gzip)几乎都来自旧节点的
|
|
4
|
+
// CurrentAliasResolver,它只读 sessions[].node_id / .alias,且 2.5 s 硬超时。经 RELAY frp 隧道到国内机器时常常
|
|
5
|
+
// 读不完就中止,Hub 已经推进隧道的字节被丢掉(约占隧道 60%)。这里钉住:
|
|
6
|
+
// - 签名只认「节点令牌 + Accept 恰好 application/json + 只有 network_id」(statusAliasResolverRead);
|
|
7
|
+
// - 命中时回 light + node_id,旧解析器照旧解析出别名(含改名之后);
|
|
8
|
+
// - 不命中的(用户令牌 / CLI 不带 Accept / 浏览器 */* / light / node_id / alias / full=1 / 多值 Accept)字节不变;
|
|
9
|
+
// - 量体积:同一批行,完整投影 vs 这条读法的 gzip 字节。
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { gzipSync } from "zlib";
|
|
16
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
19
|
+
|
|
20
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-status-alias-resolver-"));
|
|
21
|
+
let BASE = "";
|
|
22
|
+
let hub: any = null;
|
|
23
|
+
const PW = "StatusAliasResolverPassw0rd!";
|
|
24
|
+
let userToken = "", userId = "", NET = "";
|
|
25
|
+
let nodeToken = "";
|
|
26
|
+
const ROWS = 120;
|
|
27
|
+
|
|
28
|
+
function seed(i: number) {
|
|
29
|
+
const node = `node_sar_${i}`, alias = `sar-${i}`;
|
|
30
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, NET, userId]);
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, output, project_dir, config_path, hostname, ip, server, agent, version,
|
|
33
|
+
cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb,
|
|
34
|
+
process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, updated_at, last_seen_at, registered_at)
|
|
35
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, ?5, ?6, ?7, ?8, ?9, '10.0.0.' || ?10, ?9, 'agent-node:codex-app-server', '2.5.0-preview.88',
|
|
36
|
+
'1.5', '8', '61.4', '26.9', '34.5', '491.8', '442.2', '29.4', 121868288, 116.2, 0.2, 292866, 0,
|
|
37
|
+
datetime('now', '-' || ?10 || ' seconds'), datetime('now'), datetime('now', '-30 days'))`,
|
|
38
|
+
[`r_${node}`, alias, node, NET, `示例任务 ${i}:`.repeat(12), `示例输出 ${i} `.repeat(20), `/home/user/project-${i}`,
|
|
39
|
+
`/home/user/project-${i}/.anet/nodes/${alias}/config.json`, `sample-host-${i % 4}`, i],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function req(path: string, headers: Record<string, string>) {
|
|
44
|
+
const res = await fetch(`${BASE}${path}`, { headers: { "Accept-Encoding": "gzip", ...headers } });
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
return { status: res.status, text, body: JSON.parse(text) as any, projection: res.headers.get("x-status-projection") };
|
|
47
|
+
}
|
|
48
|
+
const asNode = (accept?: string) => ({ Authorization: `Bearer ${nodeToken}`, ...(accept !== undefined ? { Accept: accept } : {}) });
|
|
49
|
+
const gz = (s: string) => gzipSync(Buffer.from(s)).length;
|
|
50
|
+
|
|
51
|
+
// The exact call agent-node 2.5.0-preview.32 … .90 makes (dist/cli.js CurrentAliasResolver.fetchCanonicalAlias).
|
|
52
|
+
async function oldResolver(nodeId: string, token: string, networkId: string): Promise<string | null> {
|
|
53
|
+
try {
|
|
54
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
55
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
56
|
+
const url = `${BASE}/api/status${networkId ? `?network_id=${encodeURIComponent(networkId)}` : ""}`;
|
|
57
|
+
const ctl = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => ctl.abort(), 2500);
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(url, { headers, signal: ctl.signal });
|
|
61
|
+
if (!res.ok) return null;
|
|
62
|
+
const body = (await res.json()) as { sessions?: Array<{ node_id?: string; alias?: string }> };
|
|
63
|
+
return body.sessions?.find((s) => s.node_id === nodeId)?.alias ?? null;
|
|
64
|
+
} finally { clearTimeout(timer); }
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
70
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
71
|
+
process.env.HOST = "127.0.0.1";
|
|
72
|
+
const a = register(`sar_owner_${Date.now()}`, PW);
|
|
73
|
+
userToken = a.token!; userId = a.user!.user_id; NET = a.network_id!;
|
|
74
|
+
for (let i = 0; i < ROWS; i++) seed(i);
|
|
75
|
+
nodeToken = createNetworkTokenForNode(userId, NET, "sar-7", "node_sar_7").token!;
|
|
76
|
+
const mod: any = await import("./server.js");
|
|
77
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
78
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
79
|
+
}, 30_000);
|
|
80
|
+
|
|
81
|
+
afterAll(() => {
|
|
82
|
+
try { hub?.stop?.(true); } catch {}
|
|
83
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("statusAliasResolverRead (signature)", () => {
|
|
87
|
+
const p = (q: string) => new URLSearchParams(q);
|
|
88
|
+
test("matches only node token + Accept exactly application/json + at most network_id", () => {
|
|
89
|
+
expect(statusAliasResolverRead(p(""), "ntok_x", "application/json")).toBe(true);
|
|
90
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "application/json")).toBe(true);
|
|
91
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", " Application/JSON ")).toBe(true);
|
|
92
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "utok_x", "application/json")).toBe(false);
|
|
93
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "legacy-global-token", "application/json")).toBe(false);
|
|
94
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", null)).toBe(false);
|
|
95
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "*/*")).toBe(false);
|
|
96
|
+
expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "application/json, text/plain, */*")).toBe(false);
|
|
97
|
+
for (const q of ["light=1", "node_id=n", "alias=a", "full=1", "network_id=n&full=1", "network_id=n&light=0"]) {
|
|
98
|
+
expect(statusAliasResolverRead(p(q), "ntok_x", "application/json")).toBe(false);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("GET /api/status for the old alias resolver", () => {
|
|
104
|
+
test("old resolver still resolves its alias from the light body", async () => {
|
|
105
|
+
expect(await oldResolver("node_sar_7", nodeToken, NET)).toBe("sar-7");
|
|
106
|
+
expect(await oldResolver("node_sar_7", nodeToken, "")).toBe("sar-7");
|
|
107
|
+
// other rows are resolvable too (the resolver can be asked about a renamed peer id) and an unknown id is null
|
|
108
|
+
expect(await oldResolver("node_sar_42", nodeToken, NET)).toBe("sar-42");
|
|
109
|
+
expect(await oldResolver("node_nobody", nodeToken, NET)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("…and follows a rename (the reason the resolver exists)", async () => {
|
|
113
|
+
db.run("UPDATE sessions SET alias = 'sar-7-renamed' WHERE node_id = 'node_sar_7'");
|
|
114
|
+
try {
|
|
115
|
+
expect(await oldResolver("node_sar_7", nodeToken, NET)).toBe("sar-7-renamed");
|
|
116
|
+
} finally {
|
|
117
|
+
db.run("UPDATE sessions SET alias = 'sar-7' WHERE node_id = 'node_sar_7'");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the body is the light projection plus node_id, marked by a header", async () => {
|
|
122
|
+
const r = await req(`/api/status?network_id=${NET}`, asNode("application/json"));
|
|
123
|
+
expect(r.status).toBe(200);
|
|
124
|
+
expect(r.projection).toBe("alias-resolver");
|
|
125
|
+
expect(r.body.sessions).toHaveLength(ROWS);
|
|
126
|
+
expect(Object.keys(r.body.sessions[0]).sort()).toEqual(["agent", "alias", "network_id", "node_id", "runtime", "server", "status", "task", "updated_at"]);
|
|
127
|
+
expect(r.body.summary.total).toBe(ROWS);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("everything else is byte-identical to the full projection", async () => {
|
|
131
|
+
const full = await req(`/api/status?network_id=${NET}&full=1`, asNode("application/json"));
|
|
132
|
+
expect(full.projection).toBeNull();
|
|
133
|
+
expect(full.body.sessions[0].host).toBeDefined();
|
|
134
|
+
const same = [
|
|
135
|
+
await req(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${userToken}`, Accept: "application/json" }), // user token (app / dashboard)
|
|
136
|
+
await req(`/api/status?network_id=${NET}`, asNode()), // anet CLI: authHeaders() only
|
|
137
|
+
await req(`/api/status?network_id=${NET}`, asNode("*/*")), // browser / undici / bun default
|
|
138
|
+
await req(`/api/status?network_id=${NET}`, asNode("application/json, text/plain, */*")),
|
|
139
|
+
];
|
|
140
|
+
for (const r of same) {
|
|
141
|
+
expect(r.projection).toBeNull();
|
|
142
|
+
expect(r.body.sessions[0].host).toBeDefined();
|
|
143
|
+
expect(r.text).toBe(full.text);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("light / node_id reads keep their own exact shape", async () => {
|
|
148
|
+
const light = await req(`/api/status?network_id=${NET}&light=1`, asNode("application/json"));
|
|
149
|
+
expect(light.projection).toBeNull();
|
|
150
|
+
expect(light.body.sessions[0].node_id).toBeUndefined();
|
|
151
|
+
const one = await req(`/api/status?network_id=${NET}&node_id=node_sar_7&light=1`, asNode("application/json"));
|
|
152
|
+
expect(one.body.sessions).toHaveLength(1);
|
|
153
|
+
expect(one.body.sessions[0].node_id).toBe("node_sar_7");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("size: full projection vs the alias-resolver body (gzip)", async () => {
|
|
157
|
+
const full = await req(`/api/status?network_id=${NET}&full=1`, asNode("application/json"));
|
|
158
|
+
const slim = await req(`/api/status?network_id=${NET}`, asNode("application/json"));
|
|
159
|
+
const f = gz(full.text), s = gz(slim.text);
|
|
160
|
+
console.log(`[size] ${ROWS} rows: full ${full.text.length} B raw / ${f} B gzip → alias-resolver ${slim.text.length} B raw / ${s} B gzip (${Math.round(100 - (100 * s) / f)}% smaller)`);
|
|
161
|
+
expect(s).toBeLessThan(f / 2);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// GET /api/status 的「旧版 agent-node 找自己别名」读法识别(纯函数,可单测)。
|
|
2
|
+
//
|
|
3
|
+
// agent-node < 2.5.0-preview.93 的 CurrentAliasResolver 每 30 s 读一次全网完整 /api/status,只用
|
|
4
|
+
// sessions[].node_id / .alias,并且 2.5 s 硬超时。它的请求有一个别的客户端都不发的签名(2026-10-01 逐个核过
|
|
5
|
+
// 线上在用的 agent-node .32–.93 包、agent-network CLI 2.2.21 / 2.3.0-preview.76–.120 源码、dashboard、
|
|
6
|
+
// claude-code 节点的 .anet/node-server.js):
|
|
7
|
+
// - 节点令牌(ntok_);
|
|
8
|
+
// - Accept 恰好是 `application/json`(CLI 的 authHeaders 只带 Authorization;浏览器 / undici / bun 默认 `*/*`);
|
|
9
|
+
// - 查询串里除了 network_id 什么都没有(不带 light / node_id / alias / full)。
|
|
10
|
+
// 命中 ⇒ 回 light 投影 + node_id(它读的就这两列)。`?full=1` 永远拿完整投影。
|
|
11
|
+
|
|
12
|
+
const ALLOWED_PARAMS = new Set(["network_id"]);
|
|
13
|
+
|
|
14
|
+
export function statusAliasResolverRead(params: URLSearchParams, token: string, accept: string | null): boolean {
|
|
15
|
+
if (!token.startsWith("ntok_")) return false;
|
|
16
|
+
if ((accept ?? "").trim().toLowerCase() !== "application/json") return false;
|
|
17
|
+
for (const key of params.keys()) if (!ALLOWED_PARAMS.has(key)) return false;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
package/src/task-access.ts
CHANGED
|
@@ -192,6 +192,16 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
|
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* 项目列表的 viewer_can.edit 用:与 canUseProject 同一判据,但一个请求里只读一次授权表(列表最多 200 个项目)。
|
|
197
|
+
* 不受任务范围限制的调用者(节点 / Hub 管理员 / owner / admin / task_access='all')⇒ 每个项目都 true。
|
|
198
|
+
*/
|
|
199
|
+
export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
|
|
200
|
+
if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
|
|
201
|
+
const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
|
|
202
|
+
return (projectId) => editable.has(projectId);
|
|
203
|
+
}
|
|
204
|
+
|
|
195
205
|
/** 新建 / 挪卡时的目标项目:scoped 成员只能放进自己 can_edit 的项目(null = 不放项目,总是可以)。 */
|
|
196
206
|
export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
|
|
197
207
|
if (!caller || !projectId) return true;
|