@sleep2agi/commhub-server 0.9.0-preview.60 → 0.9.0-preview.64
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/create-node-validate.ts +34 -0
- package/src/create-node-workdir.test.ts +254 -0
- package/src/db.ts +35 -0
- package/src/inbox-count-sse.test.ts +1 -1
- package/src/requirements-http.test.ts +196 -0
- package/src/requirements.ts +256 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/server.ts +83 -5
- package/src/stop-delete-node.test.ts +212 -0
- package/src/tools.ts +87 -10
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.64",
|
|
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",
|
|
@@ -205,6 +205,40 @@ export function serializeEnvLocal(env: Record<string, string>): string {
|
|
|
205
205
|
}).join("\n") + "\n";
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// app「新建节点」工作目录 —— hub 只判**形状**(daemon 才知道那台机器的 $HOME、系统目录、
|
|
209
|
+
// 目录里住没住别的节点,那几条在 agent-node/src/runtime/child-workdir.ts)。
|
|
210
|
+
// 这里挡的是「根本不像一条路径」的输入,让它在派发前就失败,而不是占一个请求行再被 daemon 拒。
|
|
211
|
+
export const MAX_WORKDIR_LEN = 1024;
|
|
212
|
+
export function validateWorkdir(s: unknown): asserts s is string | undefined | null {
|
|
213
|
+
if (s === undefined || s === null) return;
|
|
214
|
+
if (typeof s !== "string") throw new ValidationError("workdir_invalid", { reason: "must be string" });
|
|
215
|
+
const v = s.trim();
|
|
216
|
+
if (v.length === 0 || v.length > MAX_WORKDIR_LEN) {
|
|
217
|
+
throw new ValidationError("workdir_invalid", { reason: `length 1..${MAX_WORKDIR_LEN}` });
|
|
218
|
+
}
|
|
219
|
+
if (/[\u0000-\u001f\u007f]/.test(v)) throw new ValidationError("workdir_invalid", { reason: "control character" });
|
|
220
|
+
const shapeOk = v.startsWith("/") || v === "~" || v.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(v);
|
|
221
|
+
if (!shapeOk) {
|
|
222
|
+
throw new ValidationError("workdir_invalid", { reason: "must be absolute (/…, ~/…, or C:\\…)", value: v.slice(0, 80) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** daemon 自报的默认工作目录根(`daemon_capabilities.default_workdir_root`),消毒后返回;
|
|
227
|
+
* 没报 / 形状不对 = null。**非 null 即表示该 daemon 认 `node_spec.workdir`** ——
|
|
228
|
+
* create_node 的「老 daemon 不收 workdir」门和 /api/host-supervisors 的透出共用这一个判据。 */
|
|
229
|
+
export function daemonDefaultWorkdirRoot(configSnapshot: unknown): string | null {
|
|
230
|
+
try {
|
|
231
|
+
const snap = typeof configSnapshot === "string" ? JSON.parse(configSnapshot) : configSnapshot;
|
|
232
|
+
const v = (snap as any)?.daemon_capabilities?.default_workdir_root;
|
|
233
|
+
if (typeof v !== "string" || v.length === 0 || v.length > MAX_WORKDIR_LEN) return null;
|
|
234
|
+
if (/[\u0000-\u001f\u007f]/.test(v)) return null;
|
|
235
|
+
if (!(v.startsWith("/") || /^[A-Za-z]:[\\/]/.test(v))) return null;
|
|
236
|
+
return v;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
208
242
|
// §4.2.5 — channels fail-closed in P1.
|
|
209
243
|
export function validateChannelsP1(channels: unknown): void {
|
|
210
244
|
if (channels === undefined || channels === null) return;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import "./require-explicit-test-db.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { daemonDefaultWorkdirRoot, validateWorkdir, ValidationError } from "./create-node-validate.js";
|
|
7
|
+
import { addNetworkMember, login, register } from "./auth.js";
|
|
8
|
+
|
|
9
|
+
// app「新建节点」工作目录 —— hub 这一层管三件事:
|
|
10
|
+
// ① node_spec.workdir 的**形状**(绝对 / ~/… / C:\…),派发前就拒
|
|
11
|
+
// ② daemon 没自报 default_workdir_root(= 老 daemon,会静默忽略 workdir)⇒ 拒,不派
|
|
12
|
+
// ③ 请求行存下 workdir,get_create_request 原样交给 daemon;不带就**不出现**这个键
|
|
13
|
+
// 以及 /api/host-supervisors 只给 admin/owner 透出默认根(它是那台机器的家目录路径)。
|
|
14
|
+
|
|
15
|
+
const NET = "net_workdir_gate";
|
|
16
|
+
const USER_ID = "u_workdir_admin";
|
|
17
|
+
const DAEMON_ID = "node_workdir_daemon";
|
|
18
|
+
const DAEMON_ALIAS = "workdir-daemon";
|
|
19
|
+
const DAEMON_TOK = "tok_workdir_daemon";
|
|
20
|
+
const ROOT = "/home/user";
|
|
21
|
+
|
|
22
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
23
|
+
interface Reply { ok?: boolean; error?: string; request_id?: string; node_spec?: any; [k: string]: unknown; }
|
|
24
|
+
|
|
25
|
+
function cleanup() {
|
|
26
|
+
for (const t of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
|
|
27
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
28
|
+
}
|
|
29
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
beforeEach(cleanup);
|
|
33
|
+
afterAll(cleanup);
|
|
34
|
+
|
|
35
|
+
function seed(caps: Record<string, unknown>) {
|
|
36
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [USER_ID]);
|
|
37
|
+
db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER_ID]);
|
|
38
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'admin', datetime('now'))`, [USER_ID, NET]);
|
|
39
|
+
db.run(
|
|
40
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
41
|
+
VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
|
|
42
|
+
[DAEMON_ID, DAEMON_ALIAS, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], ...caps } })],
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
46
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
47
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tools(callerTokenId: string | null = null): Record<string, ToolHandler> {
|
|
52
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
53
|
+
const out: Record<string, ToolHandler> = {};
|
|
54
|
+
const origTool = server.tool.bind(server);
|
|
55
|
+
server.tool = (name: string, d: string, schema: any, handler: ToolHandler) => { out[name] = handler; return origTool(name, d, schema, handler); };
|
|
56
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
57
|
+
if (origRegisterTool) {
|
|
58
|
+
server.registerTool = (name: string, cfg: any, handler: ToolHandler) => { out[name] = handler; return origRegisterTool(name, cfg, handler); };
|
|
59
|
+
}
|
|
60
|
+
registerTools(server, undefined, NET, USER_ID, null, true, callerTokenId);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function call(h: ToolHandler, args: any): Promise<Reply> {
|
|
65
|
+
return JSON.parse((await h(args)).content[0].text) as Reply;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseSpec = { name: "wd-child", runtime: "claude-agent-sdk", model: "claude-sonnet-4-5", flags: {} };
|
|
69
|
+
|
|
70
|
+
function rowCount(): number {
|
|
71
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1", DAEMON_ID)?.n ?? -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("validateWorkdir — shape only", () => {
|
|
75
|
+
const ok = ["/home/user/proj", "/srv/x", "~", "~/proj", "C:\\Users\\alice\\proj", "D:/work", " /padded "];
|
|
76
|
+
for (const v of ok) test(`accepts ${JSON.stringify(v)}`, () => { expect(() => validateWorkdir(v)).not.toThrow(); });
|
|
77
|
+
test("accepts undefined / null (field omitted)", () => {
|
|
78
|
+
expect(() => validateWorkdir(undefined)).not.toThrow();
|
|
79
|
+
expect(() => validateWorkdir(null)).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
const bad: unknown[] = ["", " ", "proj", "./proj", "../x", "~alice/x", "C:proj", "/a\nb", "/a\u0000b", "x".repeat(10) + "/", "/" + "a".repeat(1024), 42, {}];
|
|
82
|
+
for (const v of bad) {
|
|
83
|
+
test(`rejects ${JSON.stringify(v)?.slice(0, 40)}`, () => {
|
|
84
|
+
let code = "";
|
|
85
|
+
try { validateWorkdir(v); } catch (e) { code = (e as ValidationError).code; }
|
|
86
|
+
expect(code).toBe("workdir_invalid");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("daemonDefaultWorkdirRoot — sanitize the self-report", () => {
|
|
92
|
+
const snap = (v: unknown) => JSON.stringify({ daemon_capabilities: { default_workdir_root: v } });
|
|
93
|
+
test("absolute posix root passes through", () => { expect(daemonDefaultWorkdirRoot(snap(ROOT))).toBe(ROOT); });
|
|
94
|
+
test("windows root passes through", () => { expect(daemonDefaultWorkdirRoot(snap("C:\\Users\\alice"))).toBe("C:\\Users\\alice"); });
|
|
95
|
+
test("object snapshot (not string) also accepted", () => {
|
|
96
|
+
expect(daemonDefaultWorkdirRoot({ daemon_capabilities: { default_workdir_root: ROOT } })).toBe(ROOT);
|
|
97
|
+
});
|
|
98
|
+
for (const v of [undefined, null, "", "relative", "~/x", 7, "/a\nb", "/" + "a".repeat(1100)]) {
|
|
99
|
+
test(`not reported / malformed ${JSON.stringify(v)?.slice(0, 30)} → null`, () => {
|
|
100
|
+
expect(daemonDefaultWorkdirRoot(snap(v))).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
test("null / garbage snapshot → null", () => {
|
|
104
|
+
expect(daemonDefaultWorkdirRoot(null)).toBeNull();
|
|
105
|
+
expect(daemonDefaultWorkdirRoot("{not json")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("create_node + workdir", () => {
|
|
110
|
+
test("🔴 old daemon (no default_workdir_root) + workdir → workdir_not_supported_by_daemon, NO row", async () => {
|
|
111
|
+
seed({});
|
|
112
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: `${ROOT}/wd-child` } });
|
|
113
|
+
expect(r.ok).toBe(false);
|
|
114
|
+
expect(r.error).toBe("workdir_not_supported_by_daemon");
|
|
115
|
+
expect(rowCount()).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("old daemon WITHOUT workdir still dispatches (no regression for existing callers)", async () => {
|
|
119
|
+
seed({});
|
|
120
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
121
|
+
expect(r.ok).toBe(true);
|
|
122
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
123
|
+
expect(row?.workdir).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("bad shape rejected before dispatch → workdir_invalid, NO row", async () => {
|
|
127
|
+
seed({ default_workdir_root: ROOT });
|
|
128
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: "relative/dir" } });
|
|
129
|
+
expect(r.error).toBe("workdir_invalid");
|
|
130
|
+
expect(rowCount()).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("new daemon + workdir → row stores it and get_create_request hands it to the daemon", async () => {
|
|
134
|
+
seed({ default_workdir_root: ROOT });
|
|
135
|
+
const wd = `${ROOT}/wd-child`;
|
|
136
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
137
|
+
expect(r.ok).toBe(true);
|
|
138
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
139
|
+
expect(row?.workdir).toBe(wd);
|
|
140
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
141
|
+
expect(g.ok).toBe(true);
|
|
142
|
+
expect(g.node_spec.workdir).toBe(wd);
|
|
143
|
+
expect(g.node_spec.name).toBe(baseSpec.name);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("workdir is trimmed before storage", async () => {
|
|
147
|
+
seed({ default_workdir_root: ROOT });
|
|
148
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: ` ~/wd-child ` } });
|
|
149
|
+
expect(r.ok).toBe(true);
|
|
150
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
151
|
+
expect(row?.workdir).toBe("~/wd-child");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("new daemon WITHOUT workdir → get_create_request node_spec has NO workdir key (daemon keeps legacy layout)", async () => {
|
|
155
|
+
seed({ default_workdir_root: ROOT });
|
|
156
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
157
|
+
expect(r.ok).toBe(true);
|
|
158
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
159
|
+
expect(g.ok).toBe(true);
|
|
160
|
+
expect("workdir" in g.node_spec).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("audit detail records the requested workdir", async () => {
|
|
164
|
+
seed({ default_workdir_root: ROOT });
|
|
165
|
+
const wd = `${ROOT}/wd-child`;
|
|
166
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
167
|
+
const a = db.get<{ detail: string }>(
|
|
168
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'create_node_dispatch_attempted'", r.request_id,
|
|
169
|
+
);
|
|
170
|
+
expect(JSON.parse(a?.detail || "{}").workdir).toBe(wd);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("list_host_supervisors (MCP) surfaces default_workdir_root for admin", async () => {
|
|
174
|
+
seed({ default_workdir_root: ROOT });
|
|
175
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
176
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
177
|
+
expect(d.default_workdir_root).toBe(ROOT);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("list_host_supervisors (MCP) omits the key for an old daemon", async () => {
|
|
181
|
+
seed({});
|
|
182
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
183
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
184
|
+
expect(d).toBeDefined();
|
|
185
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── REST /api/host-supervisors: admin sees the root, member does not ──────────
|
|
190
|
+
let BASE = "";
|
|
191
|
+
let server: any = null;
|
|
192
|
+
let ownerTok = "", memberTok = "", restNet = "", ownerId = "";
|
|
193
|
+
describe("/api/host-supervisors — default_workdir_root visibility", () => {
|
|
194
|
+
beforeAll(async () => {
|
|
195
|
+
process.env.HOST = "127.0.0.1";
|
|
196
|
+
const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
197
|
+
const pw = "BootstrapPw123Aa!";
|
|
198
|
+
const s = register(`wd_seed_${sfx}`, pw, undefined, "seed");
|
|
199
|
+
if (!s.ok) throw new Error("seed failed");
|
|
200
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`wd_seed_${sfx}`]);
|
|
201
|
+
const o = register(`wd_owner_${sfx}`, pw, undefined, "seed");
|
|
202
|
+
if (!o.ok || !o.token) throw new Error("owner register failed");
|
|
203
|
+
ownerTok = o.token; restNet = o.network_id ?? "";
|
|
204
|
+
ownerId = login(`wd_owner_${sfx}`, pw).user!.user_id;
|
|
205
|
+
const m = register(`wd_member_${sfx}`, pw, undefined, "seed");
|
|
206
|
+
if (!m.ok || !m.token) throw new Error("member register failed");
|
|
207
|
+
memberTok = m.token;
|
|
208
|
+
const memberId = login(`wd_member_${sfx}`, pw).user!.user_id;
|
|
209
|
+
const add = addNetworkMember(restNet, memberId, "member");
|
|
210
|
+
if (!add.ok) throw new Error("add member failed: " + add.error);
|
|
211
|
+
const { bootServer } = await import("./server.js");
|
|
212
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
213
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
214
|
+
for (const [alias, caps] of [["wd-new", { default_workdir_root: ROOT }], ["wd-old", {}]] as const) {
|
|
215
|
+
db.run(
|
|
216
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, config_revision, config_snapshot)
|
|
217
|
+
VALUES (?1, ?2, ?2, 'claude-agent-sdk', 'm', '/tmp/cfg.json', '[]', 'h', 'h', ?3, 0, ?4)`,
|
|
218
|
+
[`wd_n_${alias}`, alias, restNet, JSON.stringify({ role: "host_supervisor", daemon_capabilities: caps })],
|
|
219
|
+
);
|
|
220
|
+
db.run(
|
|
221
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')`,
|
|
222
|
+
[`wd_t_${alias}`, `hash_wd_${alias}`, ownerId, restNet, `node:${alias}`],
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
afterAll(() => {
|
|
227
|
+
try { server?.stop?.(true); } catch {}
|
|
228
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [restNet]); } catch {}
|
|
229
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [restNet]); } catch {}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
async function list(tok: string): Promise<any[]> {
|
|
233
|
+
const res = await fetch(`${BASE}/api/host-supervisors?network_id=${encodeURIComponent(restNet)}`, { headers: { Authorization: `Bearer ${tok}` } });
|
|
234
|
+
const b = await res.json();
|
|
235
|
+
if (!b.ok) throw new Error("list failed: " + JSON.stringify(b));
|
|
236
|
+
return b.daemons;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test("owner sees default_workdir_root on the new daemon", async () => {
|
|
240
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-new");
|
|
241
|
+
expect(d?.default_workdir_root).toBe(ROOT);
|
|
242
|
+
});
|
|
243
|
+
test("old daemon row has NO default_workdir_root key (app hides the row)", async () => {
|
|
244
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-old");
|
|
245
|
+
expect(d).toBeDefined();
|
|
246
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
test("🔴 member does NOT see the host's home path", async () => {
|
|
249
|
+
const rows = await list(memberTok);
|
|
250
|
+
const d = rows.find(x => x.alias === "wd-new");
|
|
251
|
+
expect(d).toBeDefined();
|
|
252
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
package/src/db.ts
CHANGED
|
@@ -733,6 +733,15 @@ function migrateNodeCreateRequestsModelNullable() {
|
|
|
733
733
|
}
|
|
734
734
|
migrateNodeCreateRequestsModelNullable();
|
|
735
735
|
|
|
736
|
+
// app「新建节点」工作目录 —— 请求带的 node_spec.workdir(可空;空 = 落 daemon cwd 的老行为)。
|
|
737
|
+
// 🔴 必须排在上面那个重建表迁移**之后**:它按显式列清单拷贝,排在前面的话,老库(model NOT NULL)
|
|
738
|
+
// 第一次启动时这一列会被重建吞掉。
|
|
739
|
+
try {
|
|
740
|
+
db.exec(`ALTER TABLE node_create_requests ADD COLUMN workdir TEXT`);
|
|
741
|
+
} catch (e: any) {
|
|
742
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
743
|
+
}
|
|
744
|
+
|
|
736
745
|
// #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
|
|
737
746
|
// 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
|
|
738
747
|
// getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
|
|
@@ -941,6 +950,32 @@ db.exec(`
|
|
|
941
950
|
ON skillhub_skills(network_id, slug, version);
|
|
942
951
|
`);
|
|
943
952
|
|
|
953
|
+
// 需求池。长期卡片,按 network 隔离。不是 tasks。
|
|
954
|
+
db.exec(`
|
|
955
|
+
CREATE TABLE IF NOT EXISTS requirements (
|
|
956
|
+
requirement_id TEXT PRIMARY KEY,
|
|
957
|
+
network_id TEXT NOT NULL,
|
|
958
|
+
title TEXT NOT NULL,
|
|
959
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
960
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
961
|
+
due_on TEXT,
|
|
962
|
+
assignee TEXT,
|
|
963
|
+
client_id TEXT,
|
|
964
|
+
issues_json TEXT,
|
|
965
|
+
created_by TEXT,
|
|
966
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
967
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
968
|
+
);
|
|
969
|
+
CREATE INDEX IF NOT EXISTS idx_requirements_network
|
|
970
|
+
ON requirements(network_id, created_at DESC);
|
|
971
|
+
`);
|
|
972
|
+
try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
|
|
973
|
+
try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
|
|
974
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
|
|
975
|
+
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'"]) {
|
|
976
|
+
try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
977
|
+
}
|
|
978
|
+
|
|
944
979
|
// ── V3: licenses table ──
|
|
945
980
|
db.exec(`
|
|
946
981
|
CREATE TABLE IF NOT EXISTS licenses (
|
|
@@ -302,7 +302,7 @@ describe("#1440 broadcast delivery set", () => {
|
|
|
302
302
|
expect(res.message_ids).toHaveLength(3);
|
|
303
303
|
// #1440 ③ — `recipients` counted candidates, not deliveries, so it
|
|
304
304
|
// disagreed with the documented invariant
|
|
305
|
-
// ("message_ids.length === recipients", docs-site/docs/api/rest.md)
|
|
305
|
+
// ("message_ids.length === recipients", docs-site/docs/api/rest-data.md)
|
|
306
306
|
// exactly when a node was skipped. 4 sessions, 3 delivered.
|
|
307
307
|
expect(res.recipients).toBe(res.message_ids.length);
|
|
308
308
|
expect(res.recipients).toBe(3);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-requirements-"));
|
|
7
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
8
|
+
|
|
9
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
10
|
+
let base = "";
|
|
11
|
+
let ownerToken = "";
|
|
12
|
+
let viewerToken = "";
|
|
13
|
+
let otherToken = "";
|
|
14
|
+
let nodeToken = "";
|
|
15
|
+
let ownerNetwork = "";
|
|
16
|
+
|
|
17
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
18
|
+
const res = await fetch(`${base}${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
21
|
+
});
|
|
22
|
+
return { status: res.status, body: await res.json() as any };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
const { db } = await import("./db.js");
|
|
27
|
+
const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
|
|
28
|
+
const owner = register(`req_owner_${Date.now()}`, "RequirementsOwner123!", undefined, "seed");
|
|
29
|
+
expect(owner.ok).toBe(true);
|
|
30
|
+
ownerToken = owner.token!;
|
|
31
|
+
ownerNetwork = owner.network_id!;
|
|
32
|
+
const networkId = owner.network_id!;
|
|
33
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
34
|
+
const viewer = register(`req_viewer_${Date.now()}`, "RequirementsViewer123!", undefined, "seed");
|
|
35
|
+
expect(viewer.ok).toBe(true);
|
|
36
|
+
viewerToken = viewer.token!;
|
|
37
|
+
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
38
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId);
|
|
39
|
+
const ntok = createNetworkTokenForNode(ownerId, networkId, "req-node");
|
|
40
|
+
expect(ntok.ok).toBe(true);
|
|
41
|
+
nodeToken = ntok.token!;
|
|
42
|
+
const other = register(`req_other_${Date.now()}`, "RequirementsOther123!", undefined, "seed");
|
|
43
|
+
expect(other.ok).toBe(true);
|
|
44
|
+
otherToken = other.token!;
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try { server?.stop?.(true); } catch {}
|
|
52
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("requirements stay on the hub", () => {
|
|
56
|
+
let id = "";
|
|
57
|
+
|
|
58
|
+
test("typed assignments validate network membership, preserve kinds and clear explicitly", async () => {
|
|
59
|
+
const { db } = await import('./db.js');
|
|
60
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
61
|
+
db.run('INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?2,?3,?4)', [ownerId, 'same-name', '自定义 Agent 名', ownerNetwork]);
|
|
62
|
+
db.run('INSERT INTO nodes(node_id,node_name,network_id) VALUES (?1,?2,?3)', ['foreign-person-node', 'foreign-node', 'other-network']);
|
|
63
|
+
const people = await api(ownerToken, `/api/requirements/people?network_id=${ownerNetwork}`);
|
|
64
|
+
expect(people.status).toBe(200);
|
|
65
|
+
expect(people.body.people.some((p: any) => p.id === ownerId && p.kind === 'user')).toBe(true);
|
|
66
|
+
expect(people.body.people.some((p: any) => p.id === ownerId && p.kind === 'node' && p.name === '自定义 Agent 名')).toBe(true);
|
|
67
|
+
expect(people.body.people.every((p: any) => p.networkId === ownerNetwork)).toBe(true);
|
|
68
|
+
const user = { kind: 'user', id: ownerId };
|
|
69
|
+
const node = { kind: 'node', id: ownerId };
|
|
70
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'typed people', owner: user, participants: [user, node, user] }) });
|
|
71
|
+
expect(created.status).toBe(201);
|
|
72
|
+
expect(created.body.requirement.owner).toEqual(user);
|
|
73
|
+
expect(created.body.requirement.participants).toEqual([user, node]);
|
|
74
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
75
|
+
const changed = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
|
|
76
|
+
expect(changed.status).toBe(200);
|
|
77
|
+
expect(changed.body.requirement.owner).toEqual(node);
|
|
78
|
+
expect(changed.body.requirement.participants).toEqual([user, node]);
|
|
79
|
+
expect(changed.body.requirement.column).toBe('pool');
|
|
80
|
+
for (const token of [viewerToken, nodeToken]) {
|
|
81
|
+
expect((await api(token, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
|
|
82
|
+
}
|
|
83
|
+
expect((await api(otherToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null }) })).status).toBe(404);
|
|
84
|
+
for (const invalid of [{ kind: 'node', id: 'foreign-person-node' }, { kind: 'user', id: 'unknown' }, { kind: 'admin', id: ownerId }]) {
|
|
85
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ participants: [invalid] }) })).status).toBe(400);
|
|
86
|
+
}
|
|
87
|
+
const listed = await api(ownerToken, '/api/requirements');
|
|
88
|
+
expect(listed.body.requirements.find((row: any) => row.id === created.body.requirement.id).owner).toEqual(node);
|
|
89
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) });
|
|
90
|
+
expect(cleared.status).toBe(200);
|
|
91
|
+
expect(cleared.body.requirement.owner).toBeNull();
|
|
92
|
+
expect(cleared.body.requirement.participants).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("owner creates a card in the pool", async () => {
|
|
96
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
body: JSON.stringify({ name: "多端同步", priority: "high", assignee: "node-a", due: "2026-10-01" }),
|
|
99
|
+
});
|
|
100
|
+
expect(created.status).toBe(201);
|
|
101
|
+
expect(created.body.requirement.column).toBe("pool");
|
|
102
|
+
expect(created.body.requirement.name).toBe("多端同步");
|
|
103
|
+
expect(created.body.requirement.assignee).toBe("node-a");
|
|
104
|
+
expect(created.body.requirement.due).toBe("2026-10-01");
|
|
105
|
+
id = created.body.requirement.id;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the same account reads it back", async () => {
|
|
109
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
110
|
+
expect(listed.status).toBe(200);
|
|
111
|
+
expect(listed.body.requirements.some((row: { id: string }) => row.id === id)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("moving a card is stored on the hub", async () => {
|
|
115
|
+
const moved = await api(ownerToken, `/api/requirements/${id}`, {
|
|
116
|
+
method: "PATCH",
|
|
117
|
+
body: JSON.stringify({ column: "doing" }),
|
|
118
|
+
});
|
|
119
|
+
expect(moved.status).toBe(200);
|
|
120
|
+
expect(moved.body.requirement.column).toBe("doing");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("another network cannot see it", async () => {
|
|
124
|
+
const listed = await api(otherToken, "/api/requirements");
|
|
125
|
+
expect(listed.status).toBe(200);
|
|
126
|
+
expect(listed.body.requirements.some((row: { id: string }) => row.id === id)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a viewer cannot add or move", async () => {
|
|
130
|
+
const denied = await api(viewerToken, "/api/requirements", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: JSON.stringify({ name: "不行", network_id: ownerNetwork }),
|
|
133
|
+
});
|
|
134
|
+
expect(denied.status).toBe(403);
|
|
135
|
+
const move = await api(viewerToken, `/api/requirements/${id}`, {
|
|
136
|
+
method: "PATCH",
|
|
137
|
+
body: JSON.stringify({ column: "done" }),
|
|
138
|
+
});
|
|
139
|
+
expect(move.status).toBe(403);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a node token cannot use the board", async () => {
|
|
143
|
+
const denied = await api(nodeToken, "/api/requirements");
|
|
144
|
+
expect(denied.status).toBe(403);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("the same client_id does not create a second card", async () => {
|
|
148
|
+
const first = await api(ownerToken, "/api/requirements", {
|
|
149
|
+
method: "POST",
|
|
150
|
+
body: JSON.stringify({ name: "迁移甲", client_id: "local_card_1", column: "doing" }),
|
|
151
|
+
});
|
|
152
|
+
expect(first.status).toBe(201);
|
|
153
|
+
expect(first.body.requirement.column).toBe("doing");
|
|
154
|
+
const again = await api(ownerToken, "/api/requirements", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: JSON.stringify({ name: "迁移甲", client_id: "local_card_1", column: "done" }),
|
|
157
|
+
});
|
|
158
|
+
expect(again.status).toBe(200);
|
|
159
|
+
expect(again.body.requirement.id).toBe(first.body.requirement.id);
|
|
160
|
+
expect(again.body.requirement.column).toBe("doing");
|
|
161
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
162
|
+
const matches = listed.body.requirements.filter((row: { id: string }) => row.id === first.body.requirement.id);
|
|
163
|
+
expect(matches.length).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a card can bind a github issue and a bad link is rejected", async () => {
|
|
167
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
name: "绑 issue",
|
|
171
|
+
issues: [{ url: "https://github.com/sleep2agi/agent-network-app/issues/472", title: "picker" }],
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
expect(created.status).toBe(201);
|
|
175
|
+
expect(created.body.requirement.issues).toEqual([
|
|
176
|
+
{ repo: "sleep2agi/agent-network-app", number: 472, title: "picker" },
|
|
177
|
+
]);
|
|
178
|
+
const moved = await api(ownerToken, `/api/requirements/${created.body.requirement.id}`, {
|
|
179
|
+
method: "PATCH",
|
|
180
|
+
body: JSON.stringify({ column: "doing" }),
|
|
181
|
+
});
|
|
182
|
+
expect(moved.body.requirement.issues[0].number).toBe(472);
|
|
183
|
+
const bad = await api(ownerToken, `/api/requirements/${created.body.requirement.id}`, {
|
|
184
|
+
method: "PATCH",
|
|
185
|
+
body: JSON.stringify({ issues: ["javascript:alert(1)"] }),
|
|
186
|
+
});
|
|
187
|
+
expect(bad.status).toBe(400);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("empty name and a bad date are rejected", async () => {
|
|
191
|
+
const empty = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: " " }) });
|
|
192
|
+
expect(empty.status).toBe(400);
|
|
193
|
+
const date = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "日期", due: "2026-13-01" }) });
|
|
194
|
+
expect(date.status).toBe(400);
|
|
195
|
+
});
|
|
196
|
+
});
|