@sleep2agi/commhub-server 0.9.0-preview.61 → 0.9.0-preview.65
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/requirements-http.test.ts +241 -0
- package/src/requirements.ts +281 -0
- package/src/server.ts +19 -0
- package/src/tools.ts +34 -4
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.65",
|
|
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 (
|
|
@@ -0,0 +1,241 @@
|
|
|
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("an existing card can change its text without dropping omitted fields", async () => {
|
|
191
|
+
const { db } = await import("./db.js");
|
|
192
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
193
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
name: "旧标题",
|
|
197
|
+
priority: "low",
|
|
198
|
+
assignee: "node-a",
|
|
199
|
+
due: "2026-10-01",
|
|
200
|
+
column: "doing",
|
|
201
|
+
owner: { kind: "user", id: ownerId },
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
expect(created.status).toBe(201);
|
|
205
|
+
const id = created.body.requirement.id;
|
|
206
|
+
const renamed = await api(ownerToken, `/api/requirements/${id}`, {
|
|
207
|
+
method: "PATCH",
|
|
208
|
+
body: JSON.stringify({ name: "新标题" }),
|
|
209
|
+
});
|
|
210
|
+
expect(renamed.status).toBe(200);
|
|
211
|
+
expect(renamed.body.requirement.name).toBe("新标题");
|
|
212
|
+
expect(renamed.body.requirement.priority).toBe("low");
|
|
213
|
+
expect(renamed.body.requirement.assignee).toBe("node-a");
|
|
214
|
+
expect(renamed.body.requirement.due).toBe("2026-10-01");
|
|
215
|
+
expect(renamed.body.requirement.column).toBe("doing");
|
|
216
|
+
expect(renamed.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
217
|
+
const cleared = await api(ownerToken, `/api/requirements/${id}`, {
|
|
218
|
+
method: "PATCH",
|
|
219
|
+
body: JSON.stringify({ due: "", assignee: "", priority: "high" }),
|
|
220
|
+
});
|
|
221
|
+
expect(cleared.status).toBe(200);
|
|
222
|
+
expect(cleared.body.requirement.name).toBe("新标题");
|
|
223
|
+
expect(cleared.body.requirement.due).toBe("");
|
|
224
|
+
expect(cleared.body.requirement.assignee).toBe("");
|
|
225
|
+
expect(cleared.body.requirement.priority).toBe("high");
|
|
226
|
+
expect(cleared.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
227
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: " " }) })).status).toBe(400);
|
|
228
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-13-01" }) })).status).toBe(400);
|
|
229
|
+
expect((await api(viewerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
230
|
+
expect((await api(otherToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "别人的" }) })).status).toBe(404);
|
|
231
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
232
|
+
expect(listed.body.requirements.find((row: { id: string }) => row.id === id).name).toBe("新标题");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("empty name and a bad date are rejected", async () => {
|
|
236
|
+
const empty = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: " " }) });
|
|
237
|
+
expect(empty.status).toBe(400);
|
|
238
|
+
const date = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "日期", due: "2026-13-01" }) });
|
|
239
|
+
expect(date.status).toBe(400);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
|
+
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
5
|
+
|
|
6
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string } | null;
|
|
7
|
+
|
|
8
|
+
export type RequirementsRequestContext = {
|
|
9
|
+
req: Request;
|
|
10
|
+
url: URL;
|
|
11
|
+
auth: RequestAuth;
|
|
12
|
+
isAdmin: boolean;
|
|
13
|
+
isNodeToken: boolean;
|
|
14
|
+
scope: RestNetworkScope;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
18
|
+
const PRIORITIES = new Set(["high", "normal", "low"]);
|
|
19
|
+
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
20
|
+
|
|
21
|
+
type Row = {
|
|
22
|
+
owner_json: string | null;
|
|
23
|
+
participants_json: string;
|
|
24
|
+
requirement_id: string;
|
|
25
|
+
network_id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
column_name: string;
|
|
28
|
+
priority: string;
|
|
29
|
+
due_on: string | null;
|
|
30
|
+
assignee: string | null;
|
|
31
|
+
issues_json: string | null;
|
|
32
|
+
created_at: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function jsonError(error: string, status: number): Response {
|
|
36
|
+
return Response.json({ ok: false, error }, { status });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dueOk(due: string): boolean {
|
|
40
|
+
if (!DUE.test(due)) return false;
|
|
41
|
+
const [y, m, d] = due.split("-").map(Number);
|
|
42
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
43
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
47
|
+
const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
|
|
48
|
+
|
|
49
|
+
function clipTitle(value: unknown): string {
|
|
50
|
+
return typeof value === "string" ? value.replace(/[\r\n\t]/g, " ").trim().slice(0, 120) : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issueFromParts(repo: string, number: number, title: string) {
|
|
54
|
+
if (!REPO.test(repo) || !Number.isInteger(number) || number < 1 || number > 10_000_000) return null;
|
|
55
|
+
return { repo, number, title: clipTitle(title) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function oneIssue(item: unknown) {
|
|
59
|
+
if (typeof item === "string") {
|
|
60
|
+
const m = ISSUE_URL.exec(item.trim());
|
|
61
|
+
return m ? issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), "") : null;
|
|
62
|
+
}
|
|
63
|
+
if (!item || typeof item !== "object") return null;
|
|
64
|
+
const row = item as Record<string, unknown>;
|
|
65
|
+
if (typeof row.url === "string") {
|
|
66
|
+
const m = ISSUE_URL.exec(row.url.trim());
|
|
67
|
+
if (m) return issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), clipTitle(row.title));
|
|
68
|
+
}
|
|
69
|
+
const repo = typeof row.repo === "string" ? row.repo.trim() : "";
|
|
70
|
+
const number = typeof row.number === "number" ? row.number : Number(row.number);
|
|
71
|
+
return issueFromParts(repo, number, clipTitle(row.title));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeIssues(raw: unknown) {
|
|
75
|
+
if (!Array.isArray(raw) || raw.length > 8) return null;
|
|
76
|
+
const out: { repo: string; number: number; title: string }[] = [];
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const item of raw) {
|
|
79
|
+
const issue = oneIssue(item);
|
|
80
|
+
if (!issue) return null;
|
|
81
|
+
const key = `${issue.repo}#${issue.number}`;
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
out.push(issue);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function storedIssues(raw: string | null) {
|
|
90
|
+
if (!raw) return [];
|
|
91
|
+
try {
|
|
92
|
+
const parsed = normalizeIssues(JSON.parse(raw));
|
|
93
|
+
return parsed ?? [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPublic(row: Row) {
|
|
100
|
+
return {
|
|
101
|
+
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
102
|
+
participants: JSON.parse(row.participants_json || '[]'),
|
|
103
|
+
id: row.requirement_id,
|
|
104
|
+
name: row.title,
|
|
105
|
+
priority: row.priority,
|
|
106
|
+
assignee: row.assignee || "",
|
|
107
|
+
due: row.due_on || "",
|
|
108
|
+
column: row.column_name,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
issues: storedIssues(row.issues_json),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
115
|
+
const value = await req.json();
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
|
|
117
|
+
return value as Record<string, unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestContext): string | null {
|
|
121
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
122
|
+
if (typeof body.network_id === "string" && body.network_id.trim()) return body.network_id.trim();
|
|
123
|
+
return resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
127
|
+
return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json";
|
|
131
|
+
|
|
132
|
+
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
133
|
+
function personRef(value: unknown, networkId: string): PersonRef {
|
|
134
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
135
|
+
const row = value as Record<string, unknown>;
|
|
136
|
+
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
137
|
+
const exists = row.kind === 'user'
|
|
138
|
+
? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
|
|
139
|
+
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
140
|
+
if (!exists) throw new Error('person_not_in_network');
|
|
141
|
+
return { kind: row.kind, id: row.id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
|
|
145
|
+
const owner = 'owner' in body ? body.owner === null ? null : personRef(body.owner, networkId) : current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
146
|
+
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
147
|
+
if ('participants' in body) {
|
|
148
|
+
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
149
|
+
const refs = body.participants.map(value => personRef(value, networkId));
|
|
150
|
+
participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
151
|
+
}
|
|
152
|
+
return { ownerJson: owner === null ? null : JSON.stringify(owner), participantsJson: JSON.stringify(participants) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
156
|
+
const { req, url } = ctx;
|
|
157
|
+
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
158
|
+
if (ctx.isNodeToken) return jsonError("user_token_required", 403);
|
|
159
|
+
|
|
160
|
+
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
161
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
162
|
+
if (!networkId) return jsonError('network_id_required', 400);
|
|
163
|
+
const users = db.all<{ id: string; name: string }>(
|
|
164
|
+
"SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
|
|
165
|
+
);
|
|
166
|
+
const nodes = db.all<{ id: string; name: string }>(
|
|
167
|
+
"SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
|
|
168
|
+
);
|
|
169
|
+
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
173
|
+
const params: unknown[] = [];
|
|
174
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
175
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
176
|
+
sql += " ORDER BY created_at DESC LIMIT 500";
|
|
177
|
+
const rows = db.all<Row>(sql, ...params).map(toPublic);
|
|
178
|
+
return Response.json({ ok: true, requirements: rows });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
182
|
+
let body: Record<string, unknown>;
|
|
183
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
184
|
+
const networkId = writeNetwork(body, ctx);
|
|
185
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
186
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
187
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
188
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
189
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
190
|
+
if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
|
|
191
|
+
const due = typeof body.due === "string" ? body.due.trim() : "";
|
|
192
|
+
if (due && !dueOk(due)) return jsonError("invalid_due", 400);
|
|
193
|
+
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
194
|
+
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
195
|
+
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
196
|
+
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
197
|
+
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
198
|
+
if (issues === null) return jsonError("invalid_issues", 400);
|
|
199
|
+
if (clientId) {
|
|
200
|
+
const existing = db.get<Row>(
|
|
201
|
+
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
202
|
+
networkId, clientId,
|
|
203
|
+
);
|
|
204
|
+
if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
205
|
+
}
|
|
206
|
+
const id = `req_${crypto.randomUUID()}`;
|
|
207
|
+
const createdAt = new Date().toISOString();
|
|
208
|
+
let people;
|
|
209
|
+
try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
210
|
+
try {
|
|
211
|
+
db.run(
|
|
212
|
+
`INSERT INTO requirements
|
|
213
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json)
|
|
214
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13)`,
|
|
215
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson],
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
if (!clientId) return jsonError("insert_failed", 500);
|
|
219
|
+
const existing = db.get<Row>(
|
|
220
|
+
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
221
|
+
networkId, clientId,
|
|
222
|
+
);
|
|
223
|
+
if (!existing) return jsonError("insert_failed", 500);
|
|
224
|
+
return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
225
|
+
}
|
|
226
|
+
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
227
|
+
return Response.json({ ok: true, requirement: toPublic(created) }, { status: 201 });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
231
|
+
if (!match || req.method !== "PATCH") return jsonError("not_found", 404);
|
|
232
|
+
const id = decodeURIComponent(match[1]);
|
|
233
|
+
let body: Record<string, unknown>;
|
|
234
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
235
|
+
const hasColumn = 'column' in body;
|
|
236
|
+
const hasIssues = Object.prototype.hasOwnProperty.call(body, "issues");
|
|
237
|
+
const hasName = 'name' in body;
|
|
238
|
+
const hasPriority = 'priority' in body;
|
|
239
|
+
const hasDue = 'due' in body;
|
|
240
|
+
const hasAssignee = 'assignee' in body;
|
|
241
|
+
if (!hasColumn && !hasIssues && !hasName && !hasPriority && !hasDue && !hasAssignee && !('owner' in body) && !('participants' in body)) return jsonError('empty_patch', 400);
|
|
242
|
+
if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
243
|
+
const issues = hasIssues ? normalizeIssues(body.issues) : null;
|
|
244
|
+
if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
|
|
245
|
+
const params: unknown[] = [id];
|
|
246
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
247
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
248
|
+
const row = db.get<Row>(sql, ...params);
|
|
249
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
250
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
251
|
+
let name = row.title;
|
|
252
|
+
if (hasName) {
|
|
253
|
+
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
254
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
255
|
+
}
|
|
256
|
+
let priority = row.priority;
|
|
257
|
+
if (hasPriority) {
|
|
258
|
+
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
259
|
+
priority = body.priority;
|
|
260
|
+
}
|
|
261
|
+
let due = row.due_on || "";
|
|
262
|
+
if (hasDue) {
|
|
263
|
+
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
264
|
+
due = body.due.trim();
|
|
265
|
+
if (due && !dueOk(due)) return jsonError("invalid_due", 400);
|
|
266
|
+
}
|
|
267
|
+
let assignee = row.assignee || "";
|
|
268
|
+
if (hasAssignee) {
|
|
269
|
+
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
270
|
+
assignee = body.assignee.trim().slice(0, 80);
|
|
271
|
+
}
|
|
272
|
+
let people;
|
|
273
|
+
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
274
|
+
const updatedAt = new Date().toISOString();
|
|
275
|
+
db.run(
|
|
276
|
+
"UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6 WHERE requirement_id = ?3",
|
|
277
|
+
[hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee],
|
|
278
|
+
);
|
|
279
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
280
|
+
return Response.json({ ok: true, requirement: toPublic(updated) });
|
|
281
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { pendingInboxCount } from "./inbox-count.js";
|
|
|
16
16
|
import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
17
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
18
18
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
19
|
+
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
19
20
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
20
21
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
21
22
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
@@ -54,6 +55,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
|
|
|
54
55
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
55
56
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
56
57
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
58
|
+
import { handleRequirementsRequest } from "./requirements.js";
|
|
57
59
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
58
60
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
59
61
|
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
@@ -1625,6 +1627,16 @@ return Bun.serve({
|
|
|
1625
1627
|
});
|
|
1626
1628
|
if (scheduledResponse) return withCors(req, scheduledResponse);
|
|
1627
1629
|
|
|
1630
|
+
const requirementsResponse = await handleRequirementsRequest({
|
|
1631
|
+
req,
|
|
1632
|
+
url,
|
|
1633
|
+
auth: restAuth,
|
|
1634
|
+
isAdmin,
|
|
1635
|
+
isNodeToken: requestToken(req).startsWith("ntok_"),
|
|
1636
|
+
scope: restScope,
|
|
1637
|
+
});
|
|
1638
|
+
if (requirementsResponse) return withCors(req, requirementsResponse);
|
|
1639
|
+
|
|
1628
1640
|
// ── #473 REST: SSE connection detail (ops-only) ──
|
|
1629
1641
|
// The per-key breakdown /health used to expose anonymously: keys are
|
|
1630
1642
|
// `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
|
|
@@ -3419,6 +3431,13 @@ return Bun.serve({
|
|
|
3419
3431
|
out.create_capability_observed_ms_ago = observedMsAgo;
|
|
3420
3432
|
}
|
|
3421
3433
|
}
|
|
3434
|
+
// app「新建节点」确认页的默认工作目录根。出现即表示该 daemon 认 node_spec.workdir;
|
|
3435
|
+
// 缺席(老 daemon / 老 hub 的 zod 剥掉了它)时 app 藏起那一行、不发 workdir。
|
|
3436
|
+
// 🔴 它是那台机器的家目录路径 —— 与 ip_internal 同级,只给 admin/owner。
|
|
3437
|
+
if (isPrivileged) {
|
|
3438
|
+
const workdirRoot = daemonDefaultWorkdirRoot(r.config_snapshot);
|
|
3439
|
+
if (workdirRoot) out.default_workdir_root = workdirRoot;
|
|
3440
|
+
}
|
|
3422
3441
|
return out;
|
|
3423
3442
|
});
|
|
3424
3443
|
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
package/src/tools.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
validateRuntime,
|
|
16
16
|
validateModel,
|
|
17
17
|
validateChannelsP1,
|
|
18
|
+
validateWorkdir,
|
|
19
|
+
daemonDefaultWorkdirRoot,
|
|
18
20
|
validateEnvRefs,
|
|
19
21
|
FLAG_KEYS,
|
|
20
22
|
validateFlagValue,
|
|
@@ -712,6 +714,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
712
714
|
// 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
|
|
713
715
|
// 「刚测的」「很久以前测的」「不知道」分成三件事说。
|
|
714
716
|
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
717
|
+
// app「新建节点」工作目录:daemon 的默认根。出现即表示该 daemon 认 node_spec.workdir。
|
|
718
|
+
// `.catch(undefined)`:同上一格的立场 —— 一个诊断/展示字段不许拒掉整份 report。
|
|
719
|
+
// 读取侧再按形状消毒(create-node-validate.ts daemonDefaultWorkdirRoot)。
|
|
720
|
+
default_workdir_root: z.string().max(1024).optional().catch(undefined),
|
|
715
721
|
}).optional(),
|
|
716
722
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
717
723
|
// app#225 follow-up — the reporting process answers the `rules_file`
|
|
@@ -3740,6 +3746,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3740
3746
|
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
3741
3747
|
telemetry.ip_internal = r.session_ip ?? null;
|
|
3742
3748
|
}
|
|
3749
|
+
// 工作目录默认根会暴露那台机器的家目录路径 —— 与 IP 同级,只给 admin/owner
|
|
3750
|
+
// (也只有他们能 create_node)。见 create-node-validate.ts daemonDefaultWorkdirRoot。
|
|
3751
|
+
const workdirRoot = isPrivileged ? daemonDefaultWorkdirRoot(r.config_snapshot) : null;
|
|
3743
3752
|
return {
|
|
3744
3753
|
daemon_node_id: r.node_id,
|
|
3745
3754
|
alias: r.alias,
|
|
@@ -3749,6 +3758,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3749
3758
|
runtimes_supported: runtimes,
|
|
3750
3759
|
allowed_secret_keys: secrets,
|
|
3751
3760
|
host_telemetry: telemetry,
|
|
3761
|
+
...(workdirRoot ? { default_workdir_root: workdirRoot } : {}),
|
|
3752
3762
|
};
|
|
3753
3763
|
});
|
|
3754
3764
|
|
|
@@ -3771,6 +3781,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3771
3781
|
flags: z.record(z.string(), z.unknown()).optional(),
|
|
3772
3782
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
3773
3783
|
channels: z.array(z.unknown()).optional(),
|
|
3784
|
+
// app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
|
|
3785
|
+
// 缺席 = 老行为:落 daemon 的 cwd。
|
|
3786
|
+
workdir: z.string().max(1024).optional().nullable(),
|
|
3774
3787
|
}),
|
|
3775
3788
|
network_id: z.string().max(200).optional(),
|
|
3776
3789
|
},
|
|
@@ -3915,6 +3928,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3915
3928
|
validateRuntime(node_spec.runtime);
|
|
3916
3929
|
validateModel(node_spec.model);
|
|
3917
3930
|
validateChannelsP1((node_spec as any).channels);
|
|
3931
|
+
validateWorkdir(node_spec.workdir);
|
|
3918
3932
|
for (const [k, v] of Object.entries(node_spec.flags || {})) {
|
|
3919
3933
|
if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
|
|
3920
3934
|
validateFlagValue(k, v);
|
|
@@ -3922,6 +3936,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3922
3936
|
} catch (e) {
|
|
3923
3937
|
return validationFailReply(e);
|
|
3924
3938
|
}
|
|
3939
|
+
// 🔴 老 daemon 会**静默忽略** node_spec.workdir,把节点建在它自己的 cwd 里 ——
|
|
3940
|
+
// 用户以为指定了目录,实际落到了别处(常常就是 $HOME)。所以没自报支持的 daemon
|
|
3941
|
+
// 直接拒,而不是派一个会被悄悄改写的请求。app 在这种 daemon 上本就不显示那一行。
|
|
3942
|
+
const requestedWorkdir = typeof node_spec.workdir === "string" ? node_spec.workdir.trim() : null;
|
|
3943
|
+
if (requestedWorkdir && !daemonDefaultWorkdirRoot(daemon.config_snapshot)) {
|
|
3944
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3945
|
+
ok: false,
|
|
3946
|
+
error: "workdir_not_supported_by_daemon",
|
|
3947
|
+
hint: "this daemon's agent-node predates create-node workdir support; upgrade it, or omit workdir to use its current directory",
|
|
3948
|
+
daemon_node_id,
|
|
3949
|
+
}) }] };
|
|
3950
|
+
}
|
|
3925
3951
|
|
|
3926
3952
|
// P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
|
|
3927
3953
|
// enforce at hub for fast-fail; daemon repeats.
|
|
@@ -4007,12 +4033,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4007
4033
|
const envKeys = Object.keys(envBlob);
|
|
4008
4034
|
db.run(
|
|
4009
4035
|
`INSERT INTO node_create_requests
|
|
4010
|
-
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
4011
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
4036
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, workdir)
|
|
4037
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11, ?12)`,
|
|
4012
4038
|
[
|
|
4013
4039
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
4014
4040
|
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
4015
4041
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
4042
|
+
requestedWorkdir,
|
|
4016
4043
|
],
|
|
4017
4044
|
);
|
|
4018
4045
|
|
|
@@ -4035,6 +4062,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4035
4062
|
model: node_spec.model ?? null,
|
|
4036
4063
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
4037
4064
|
env_keys: envKeys,
|
|
4065
|
+
workdir: requestedWorkdir,
|
|
4038
4066
|
},
|
|
4039
4067
|
});
|
|
4040
4068
|
|
|
@@ -4168,8 +4196,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4168
4196
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
4169
4197
|
}
|
|
4170
4198
|
// Hydrate spec from row.
|
|
4171
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
|
|
4172
|
-
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
4199
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string; workdir: string | null }>(
|
|
4200
|
+
`SELECT child_name, runtime, model, flags_json, workdir FROM node_create_requests WHERE request_id = ?1`,
|
|
4173
4201
|
request_id,
|
|
4174
4202
|
);
|
|
4175
4203
|
if (!specRow) {
|
|
@@ -4192,6 +4220,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4192
4220
|
model: specRow.model,
|
|
4193
4221
|
flags: JSON.parse(specRow.flags_json),
|
|
4194
4222
|
channels: [],
|
|
4223
|
+
// 只在请求真带了时出现:老 daemon 看不到多余的键,新 daemon 缺席即走老布局。
|
|
4224
|
+
...(specRow.workdir ? { workdir: specRow.workdir } : {}),
|
|
4195
4225
|
},
|
|
4196
4226
|
child_token: blob.child_token,
|
|
4197
4227
|
env_blob: blob.env_blob,
|