@sleep2agi/commhub-server 0.9.0-preview.112 → 0.9.0-preview.114
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/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth.ts +86 -15
- package/src/db.ts +44 -1
- package/src/node-permissions.ts +4 -1
- package/src/requirement-due-reminders.ts +3 -3
- package/src/requirement-notify.ts +1 -1
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-errors.ts +4 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +2 -2
- package/src/requirements-migrate.ts +34 -0
- package/src/requirements-priority-lowest.test.ts +1 -1
- package/src/requirements-stats.ts +4 -1
- package/src/requirements.ts +59 -10
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -1
- package/src/schedule-agent-mcp-http.test.ts +297 -0
- package/src/schedule-agent.ts +170 -0
- package/src/scheduled-tasks.ts +174 -89
- package/src/server.ts +27 -8
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/tool-audience-http.test.ts +8 -1
- package/src/tool-audience.ts +3 -0
- package/src/tools.ts +169 -16
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.114",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// #711 改密码只让浏览器 / app 的登录会话下线,命令行和脚本令牌默认保留。
|
|
2
|
+
//
|
|
3
|
+
// main 上 POST /api/auth/password 跑的是 `DELETE FROM api_tokens WHERE user_id = ? AND network_id IS NULL
|
|
4
|
+
// AND token_id != ?` —— 每台机器上 `anet login` 拿到的令牌、POST /api/auth/tokens 建的具名脚本令牌,
|
|
5
|
+
// 跟浏览器会话一起被删光。安全复审定的形状:默认不变,保留是显式选项。这里每条都钉住:
|
|
6
|
+
// · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
|
|
7
|
+
// · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
|
|
8
|
+
// · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
|
|
9
|
+
// · 节点 / 网络令牌永远不动;
|
|
10
|
+
// · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
|
|
11
|
+
// SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
|
|
19
|
+
import { db, hashToken } from "./db.js";
|
|
20
|
+
|
|
21
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
const PW = "PwChangeKindPassw0rd!";
|
|
25
|
+
const PW2 = "PwChangeKindPassw0rd!2";
|
|
26
|
+
const CLI_LABEL = "anet 2.5.0 · test-box · login";
|
|
27
|
+
|
|
28
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
29
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
35
|
+
}
|
|
36
|
+
const works = async (token: string) => (await call(token, "GET", "/api/auth/me")).status === 200;
|
|
37
|
+
const kindOf = (token: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_hash = ?1", hashToken(token))?.kind ?? null;
|
|
38
|
+
|
|
39
|
+
type Fleet = {
|
|
40
|
+
user: string; userId: string; net: string;
|
|
41
|
+
app: string; appB: string; regToken: string;
|
|
42
|
+
cliByLabel: string; cliByKind: string; apiToken: string;
|
|
43
|
+
nodeToken: string; netToken: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let seq = 0;
|
|
47
|
+
async function fleet(): Promise<Fleet> {
|
|
48
|
+
const user = `pwk_${Date.now()}_${++seq}`;
|
|
49
|
+
const reg = register(user, PW);
|
|
50
|
+
expect(reg.ok).toBe(true);
|
|
51
|
+
const login = async (extra: Record<string, unknown>) => {
|
|
52
|
+
const r = await call("", "POST", "/api/auth/login", { username: user, password: PW, ...extra });
|
|
53
|
+
expect(r.status).toBe(200);
|
|
54
|
+
return r.body.token as string;
|
|
55
|
+
};
|
|
56
|
+
const node = createNetworkTokenForNode(reg.user!.user_id, reg.network_id!, "pwk-node", `node_pwk_${seq}`);
|
|
57
|
+
expect(node.ok).toBe(true);
|
|
58
|
+
const api = createToken(reg.user!.user_id, "ci-script");
|
|
59
|
+
expect(api.ok).toBe(true);
|
|
60
|
+
const netTok = createToken(reg.user!.user_id, "net-script", reg.network_id!);
|
|
61
|
+
expect(netTok.ok).toBe(true);
|
|
62
|
+
return {
|
|
63
|
+
user, userId: reg.user!.user_id, net: reg.network_id!,
|
|
64
|
+
regToken: reg.token!,
|
|
65
|
+
app: await login({ client_label: "Desktop app" }),
|
|
66
|
+
appB: await login({}),
|
|
67
|
+
cliByLabel: await login({ client_label: CLI_LABEL }),
|
|
68
|
+
cliByKind: await login({ client_kind: "cli" }),
|
|
69
|
+
apiToken: api.token!,
|
|
70
|
+
nodeToken: node.token!,
|
|
71
|
+
netToken: netTok.token!,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
77
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
78
|
+
process.env.HOST = "127.0.0.1";
|
|
79
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
80
|
+
const mod: any = await import("./server.js");
|
|
81
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
82
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
83
|
+
}, 30_000);
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
try { hub?.stop?.(true); } catch {}
|
|
87
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("new tokens are classified at issue time", () => {
|
|
91
|
+
test("browser/app logins are 'login'; anet-labelled, client_kind=cli and named API tokens are 'cli'; node tokens unclassified", async () => {
|
|
92
|
+
const f = await fleet();
|
|
93
|
+
expect(kindOf(f.regToken)).toBe("login");
|
|
94
|
+
expect(kindOf(f.app)).toBe("login");
|
|
95
|
+
expect(kindOf(f.appB)).toBe("login");
|
|
96
|
+
expect(kindOf(f.cliByLabel)).toBe("cli");
|
|
97
|
+
expect(kindOf(f.cliByKind)).toBe("cli");
|
|
98
|
+
expect(kindOf(f.apiToken)).toBe("cli");
|
|
99
|
+
expect(kindOf(f.nodeToken)).toBe(null);
|
|
100
|
+
expect(kindOf(f.netToken)).toBe(null);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 响应里出现任何令牌值都算泄漏:换发给调用方的那一条(body.token)除外,它本来就要交给调用方。
|
|
105
|
+
function assertNoTokenValues(body: any, f: Fleet) {
|
|
106
|
+
const { token: _rotated, ...rest } = body;
|
|
107
|
+
const text = JSON.stringify(rest);
|
|
108
|
+
for (const t of [f.regToken, f.app, f.appB, f.cliByLabel, f.cliByKind, f.apiToken, f.nodeToken, f.netToken]) {
|
|
109
|
+
expect(text.includes(t)).toBe(false);
|
|
110
|
+
}
|
|
111
|
+
expect(/\b(utok|atok|ntok)_[A-Za-z0-9]{8,}/.test(text)).toBe(false);
|
|
112
|
+
expect(text.includes("token_hash")).toBe(false);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("POST /api/auth/password (default: old apps, dashboard proxy — no field)", () => {
|
|
116
|
+
test("every other non-network token is revoked (pre-#711 behaviour); node tokens and the rotated current session survive", async () => {
|
|
117
|
+
const f = await fleet();
|
|
118
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
119
|
+
expect(r.status).toBe(200);
|
|
120
|
+
expect(r.body.ok).toBe(true);
|
|
121
|
+
expect(r.body.revoked_login).toBe(2); // reg + appB
|
|
122
|
+
expect(r.body.revoked_cli).toBe(3); // cliByLabel + cliByKind + apiToken
|
|
123
|
+
expect(r.body.revoked).toBe(5);
|
|
124
|
+
expect(r.body.kept_cli_tokens).toBeUndefined();
|
|
125
|
+
for (const t of [f.regToken, f.appB, f.cliByLabel, f.cliByKind, f.apiToken]) expect(await works(t)).toBe(false);
|
|
126
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
127
|
+
expect(await works(f.netToken)).toBe(true);
|
|
128
|
+
expect(await works(r.body.token)).toBe(true);
|
|
129
|
+
expect(kindOf(r.body.token)).toBe("login");
|
|
130
|
+
expect(await works(f.app)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a client-declared cli token (client_kind / anet label) does NOT survive a default password change", async () => {
|
|
134
|
+
const f = await fleet();
|
|
135
|
+
expect(kindOf(f.cliByKind)).toBe("cli");
|
|
136
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(await works(f.cliByKind)).toBe(false);
|
|
139
|
+
expect(await works(f.cliByLabel)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a non-boolean keep_cli_tokens is not an opt-in; the removed revoke_cli_tokens field does nothing", async () => {
|
|
143
|
+
const f = await fleet();
|
|
144
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: "true", revoke_cli_tokens: false });
|
|
145
|
+
expect(r.status).toBe(200);
|
|
146
|
+
expect(r.body.revoked_cli).toBe(3);
|
|
147
|
+
expect(await works(f.cliByLabel)).toBe(false);
|
|
148
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("POST /api/auth/password with keep_cli_tokens=true (anet passwd)", () => {
|
|
153
|
+
test("only other login sessions are revoked; CLI/script tokens are kept and listed without token values", async () => {
|
|
154
|
+
const f = await fleet();
|
|
155
|
+
const r = await call(f.cliByLabel, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true });
|
|
156
|
+
expect(r.status).toBe(200);
|
|
157
|
+
expect(r.body.revoked_login).toBe(3); // reg + app + appB
|
|
158
|
+
expect(r.body.revoked_cli).toBe(0);
|
|
159
|
+
expect(r.body.revoked).toBe(3);
|
|
160
|
+
for (const t of [f.regToken, f.app, f.appB]) expect(await works(t)).toBe(false);
|
|
161
|
+
expect(await works(f.cliByKind)).toBe(true);
|
|
162
|
+
expect(await works(f.apiToken)).toBe(true);
|
|
163
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
164
|
+
expect(await works(f.netToken)).toBe(true);
|
|
165
|
+
// 当前会话:换发的新令牌可用,沿用 cli
|
|
166
|
+
expect(await works(r.body.token)).toBe(true);
|
|
167
|
+
expect(kindOf(r.body.token)).toBe("cli");
|
|
168
|
+
// 保留清单:不含当前这条(它已被换掉),只含另外两条 cli;字段齐全、无令牌值
|
|
169
|
+
const kept = r.body.kept_cli_tokens as any[];
|
|
170
|
+
const idOf = (t: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(t)).token_id;
|
|
171
|
+
expect(kept.map((k) => k.token_id).sort()).toEqual([idOf(f.cliByKind), idOf(f.apiToken)].sort());
|
|
172
|
+
for (const k of kept) {
|
|
173
|
+
expect(Object.keys(k).sort()).toEqual(["client_label", "created_at", "last_used_at", "name", "token_id"]);
|
|
174
|
+
expect(k.token_id).toMatch(/^tok_[0-9a-f]{12}$/);
|
|
175
|
+
}
|
|
176
|
+
expect(kept.find((k) => k.token_id === idOf(f.apiToken)).name).toBe("ci-script");
|
|
177
|
+
assertNoTokenValues(r.body, f);
|
|
178
|
+
// 可以逐条撤销
|
|
179
|
+
expect((await call(r.body.token, "DELETE", `/api/auth/tokens/${idOf(f.apiToken)}`)).status).toBe(200);
|
|
180
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("default response carries no token values either", async () => {
|
|
184
|
+
const f = await fleet();
|
|
185
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
186
|
+
expect(r.status).toBe(200);
|
|
187
|
+
assertNoTokenValues(r.body, f);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("changePassword() keeps the current token row in both modes", () => {
|
|
192
|
+
test("no opts at all = default: CLI tokens revoked, current kept", async () => {
|
|
193
|
+
const f = await fleet();
|
|
194
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
195
|
+
const r = changePassword(f.userId, PW, PW2, cur);
|
|
196
|
+
expect(r.ok).toBe(true);
|
|
197
|
+
expect(r.revoked_cli).toBe(3);
|
|
198
|
+
expect(r.kept_cli_tokens).toBeUndefined();
|
|
199
|
+
expect(await works(f.app)).toBe(true);
|
|
200
|
+
expect(await works(f.cliByKind)).toBe(false);
|
|
201
|
+
expect(await works(f.apiToken)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
for (const keepCliTokens of [false, true]) {
|
|
205
|
+
test(`keepCliTokens=${keepCliTokens}: current login token kept`, async () => {
|
|
206
|
+
const f = await fleet();
|
|
207
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
208
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
|
|
209
|
+
expect(r.ok).toBe(true);
|
|
210
|
+
expect(await works(f.app)).toBe(true);
|
|
211
|
+
expect(await works(f.appB)).toBe(false);
|
|
212
|
+
expect(await works(f.cliByLabel)).toBe(keepCliTokens);
|
|
213
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
test(`keepCliTokens=${keepCliTokens}: current CLI token kept`, async () => {
|
|
216
|
+
const f = await fleet();
|
|
217
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.cliByKind)).token_id;
|
|
218
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
|
|
219
|
+
expect(r.ok).toBe(true);
|
|
220
|
+
expect(await works(f.cliByKind)).toBe(true);
|
|
221
|
+
expect(await works(f.app)).toBe(false);
|
|
222
|
+
expect(await works(f.apiToken)).toBe(keepCliTokens);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
|
|
228
|
+
// 把 kind 列拿掉 = 回到 #711 之前的表结构;塞存量行;再起一个进程加载 db.ts(= Hub 启动迁移)。
|
|
229
|
+
function bootMigration() {
|
|
230
|
+
const r = spawnSync(process.execPath, ["-e", `await import(${JSON.stringify(join(import.meta.dir, "db.ts"))}); process.exit(0);`], {
|
|
231
|
+
cwd: join(import.meta.dir, ".."),
|
|
232
|
+
env: { ...process.env, NODE_ENV: "test" },
|
|
233
|
+
encoding: "utf8",
|
|
234
|
+
timeout: 60_000,
|
|
235
|
+
});
|
|
236
|
+
if (r.status !== 0) console.log(String(r.stderr || "").slice(-2000));
|
|
237
|
+
return r.status;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test("anet-labelled and named API tokens → cli, everything else → login, node tokens untouched; idempotent", async () => {
|
|
241
|
+
const f = await fleet();
|
|
242
|
+
db.exec("ALTER TABLE api_tokens DROP COLUMN kind");
|
|
243
|
+
const ins = (id: string, net: string | null, scope: string, label: string | null) =>
|
|
244
|
+
db.run(
|
|
245
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, 'fixture', ?5, ?6, 1)",
|
|
246
|
+
[id, hashToken(`fixture-${id}`), f.userId, net, scope, label],
|
|
247
|
+
);
|
|
248
|
+
const id = (s: string) => `tok_fx_${seq}_${s}`;
|
|
249
|
+
ins(id("cli_label"), null, "user", "anet 2.4.0 · old-box · login");
|
|
250
|
+
ins(id("cli_demo"), null, "user", "anet dev · unknown-host · demo sci-team");
|
|
251
|
+
ins(id("browser"), null, "user", "Chrome on macOS");
|
|
252
|
+
ins(id("nolabel"), null, "user", null);
|
|
253
|
+
ins(id("lookalike"), null, "user", "planet-anet dashboard");
|
|
254
|
+
ins(id("upper"), null, "user", "ANET 2.4.0 · box · login");
|
|
255
|
+
ins(id("padded"), null, "user", " anet 2.4.0 · box · login");
|
|
256
|
+
ins(id("named"), null, "full", null);
|
|
257
|
+
ins(id("node"), f.net, "network", null);
|
|
258
|
+
ins(id("netfull"), f.net, "full", null);
|
|
259
|
+
|
|
260
|
+
expect(bootMigration()).toBe(0);
|
|
261
|
+
const k = (s: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_id = ?1", id(s))?.kind ?? null;
|
|
262
|
+
expect(k("cli_label")).toBe("cli");
|
|
263
|
+
expect(k("cli_demo")).toBe("cli");
|
|
264
|
+
expect(k("browser")).toBe("login");
|
|
265
|
+
expect(k("nolabel")).toBe("login");
|
|
266
|
+
expect(k("lookalike")).toBe("login");
|
|
267
|
+
expect(k("upper")).toBe("login"); // 区分大小写:SQLite 的 LIKE 会把它当 cli,PG 不会 —— 两边必须一致
|
|
268
|
+
expect(k("padded")).toBe("cli"); // 与运行时一样先 trim
|
|
269
|
+
expect(k("named")).toBe("cli");
|
|
270
|
+
expect(k("node")).toBe(null);
|
|
271
|
+
expect(k("netfull")).toBe(null);
|
|
272
|
+
// fleet() 的 live 令牌也被回填(列重建后原值丢了,按同一规则算回来)
|
|
273
|
+
expect(kindOf(f.cliByLabel)).toBe("cli");
|
|
274
|
+
expect(kindOf(f.apiToken)).toBe("cli");
|
|
275
|
+
expect(kindOf(f.app)).toBe("login");
|
|
276
|
+
|
|
277
|
+
// 幂等:已有值不被改写(手动改成 login 的 anet 行保持 login),再跑一次结果不变
|
|
278
|
+
db.run("UPDATE api_tokens SET kind = 'login' WHERE token_id = ?1", [id("cli_label")]);
|
|
279
|
+
expect(bootMigration()).toBe(0);
|
|
280
|
+
expect(k("cli_label")).toBe("login");
|
|
281
|
+
expect(k("named")).toBe("cli");
|
|
282
|
+
expect(k("browser")).toBe("login");
|
|
283
|
+
|
|
284
|
+
// 迁移后的库上,keep_cli_tokens 保留回填成 cli 的存量令牌
|
|
285
|
+
const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
|
|
286
|
+
const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens: true });
|
|
287
|
+
expect(r.ok).toBe(true);
|
|
288
|
+
expect(k("named")).toBe("cli");
|
|
289
|
+
expect(k("cli_demo")).toBe("cli");
|
|
290
|
+
expect(k("browser")).toBe(null); // 被撤销(行已删除)
|
|
291
|
+
expect(k("node")).toBe(null);
|
|
292
|
+
expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
|
|
293
|
+
}, 120_000);
|
|
294
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -137,8 +137,8 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
137
137
|
const userToken = generateUserToken();
|
|
138
138
|
const userTokenId = generateId("tok");
|
|
139
139
|
db.run(
|
|
140
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
|
|
141
|
-
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
|
|
140
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
141
|
+
[userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256), userTokenKindFor(opts.client)]
|
|
142
142
|
);
|
|
143
143
|
|
|
144
144
|
// Network token (ntok_) — bound to default network, for agent-node
|
|
@@ -217,8 +217,8 @@ export function login(username: string, password: string, client: SessionClientI
|
|
|
217
217
|
const userToken = generateUserToken();
|
|
218
218
|
const tokenId = generateId("tok");
|
|
219
219
|
db.run(
|
|
220
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
|
|
221
|
-
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
|
|
220
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
|
|
221
|
+
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256), userTokenKindFor(client)]
|
|
222
222
|
);
|
|
223
223
|
|
|
224
224
|
// Find default network
|
|
@@ -321,7 +321,28 @@ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
|
|
|
321
321
|
// 以小时为粒度对一个以天计的闲置窗口没有影响。
|
|
322
322
|
const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
|
|
323
323
|
|
|
324
|
-
export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
|
|
324
|
+
export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: unknown };
|
|
325
|
+
|
|
326
|
+
// #711 不绑网络的用户令牌的种类(api_tokens.kind):
|
|
327
|
+
// 'login' — 浏览器 / app 的登录会话;
|
|
328
|
+
// 'cli' — anet 命令行登录、POST /api/auth/tokens 建的具名脚本令牌。
|
|
329
|
+
// 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
|
|
330
|
+
// 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
|
|
331
|
+
// 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
|
|
332
|
+
// 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
|
|
333
|
+
export type UserTokenKind = "login" | "cli";
|
|
334
|
+
|
|
335
|
+
/** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
|
|
336
|
+
/** anet 自报的 client_label:去掉首尾空白后,区分大小写地以「anet 」开头(启动回填用同一条规则,见 db.ts)。 */
|
|
337
|
+
export function isAnetClientLabel(label: string): boolean {
|
|
338
|
+
return label.trim().startsWith("anet ");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function userTokenKindFor(client: SessionClientInfo = {}): UserTokenKind {
|
|
342
|
+
if (client.kind === "cli") return "cli";
|
|
343
|
+
if (typeof client.label === "string" && isAnetClientLabel(client.label)) return "cli";
|
|
344
|
+
return "login";
|
|
345
|
+
}
|
|
325
346
|
|
|
326
347
|
function cleanClientField(value: unknown, max: number): string | null {
|
|
327
348
|
if (typeof value !== "string") return null;
|
|
@@ -508,9 +529,10 @@ export function createToken(userId: string, name: string, networkId?: string): {
|
|
|
508
529
|
}
|
|
509
530
|
const token = generateToken();
|
|
510
531
|
const tokenId = generateId("tok");
|
|
532
|
+
// #711 不绑网络的具名令牌是给脚本 / 命令行长期用的:kind='cli'(改密码时可显式 keep_cli_tokens 保留)。
|
|
511
533
|
db.run(
|
|
512
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
|
|
513
|
-
[tokenId, hashToken(token), userId, networkId || null, name, "full"]
|
|
534
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)",
|
|
535
|
+
[tokenId, hashToken(token), userId, networkId || null, name, "full", networkId ? null : "cli"]
|
|
514
536
|
);
|
|
515
537
|
return { ok: true, token, token_id: tokenId };
|
|
516
538
|
}
|
|
@@ -520,16 +542,55 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
|
|
|
520
542
|
return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
|
|
521
543
|
}
|
|
522
544
|
|
|
523
|
-
export function issueUserToken(
|
|
545
|
+
export function issueUserToken(
|
|
546
|
+
userId: string,
|
|
547
|
+
name = "user-login",
|
|
548
|
+
opts: { kind?: UserTokenKind; clientLabel?: string | null; userAgent?: string | null } = {},
|
|
549
|
+
): { token: string; token_id: string } {
|
|
524
550
|
const token = generateUserToken();
|
|
525
551
|
const tokenId = generateId("tok");
|
|
526
552
|
db.run(
|
|
527
|
-
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', 1)",
|
|
528
|
-
[tokenId, hashToken(token), userId, name]
|
|
553
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', ?5, ?6, ?7, 1)",
|
|
554
|
+
[tokenId, hashToken(token), userId, name, opts.clientLabel ?? null, opts.userAgent ?? null, opts.kind === "cli" ? "cli" : "login"]
|
|
529
555
|
);
|
|
530
556
|
return { token, token_id: tokenId };
|
|
531
557
|
}
|
|
532
558
|
|
|
559
|
+
/** 一条令牌的种类和设备标识(改密码换发新令牌时沿用,命令行的令牌换完还是命令行的)。 */
|
|
560
|
+
export function getUserTokenMeta(userId: string, tokenId: string): { kind: UserTokenKind; client_label: string | null; user_agent: string | null } | null {
|
|
561
|
+
const row = db.get<any>("SELECT kind, client_label, user_agent FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND network_id IS NULL", tokenId, userId);
|
|
562
|
+
if (!row) return null;
|
|
563
|
+
return { kind: row.kind === "cli" ? "cli" : "login", client_label: row.client_label ?? null, user_agent: row.user_agent ?? null };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// #711 改密码撤销哪些令牌。kind 为 NULL 的(启动回填之后由旧版 Hub 签出的)按 'login' 算。
|
|
567
|
+
const CLI_KIND_SQL = "kind = 'cli'";
|
|
568
|
+
const NOT_CLI_KIND_SQL = "COALESCE(kind, 'login') <> 'cli'";
|
|
569
|
+
|
|
570
|
+
/** 改密码后保留下来的命令行 / 脚本令牌(给 UI 列出来、让用户逐条撤销)。**永远不含令牌值。** */
|
|
571
|
+
export type KeptCliToken = { token_id: string; name: string; client_label: string | null; created_at: string; last_used_at: string | null };
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 改密码后的令牌撤销。默认(keepCli=false)= 改密码前的行为:撤销全部不绑网络的令牌;
|
|
575
|
+
* keepCli=true 时保留 kind='cli' 的,并把它们列出来。当前这条(exceptTokenId)和节点 / 网络令牌永远不动。
|
|
576
|
+
*/
|
|
577
|
+
export function revokeTokensAfterPasswordChange(userId: string, exceptTokenId: string | null | undefined, keepCli: boolean): { login: number; cli: number; kept: KeptCliToken[] } {
|
|
578
|
+
const except = exceptTokenId ? " AND token_id != ?2" : "";
|
|
579
|
+
const params = exceptTokenId ? [userId, exceptTokenId] : [userId];
|
|
580
|
+
const login = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND ${NOT_CLI_KIND_SQL}${except}`, params).changes;
|
|
581
|
+
if (!keepCli) {
|
|
582
|
+
const cli = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL${except}`, params).changes;
|
|
583
|
+
return { login, cli, kept: [] };
|
|
584
|
+
}
|
|
585
|
+
const kept = db.all<any>(
|
|
586
|
+
`SELECT token_id, name, client_label, created_at, last_used_at FROM api_tokens
|
|
587
|
+
WHERE user_id = ?1 AND network_id IS NULL AND ${CLI_KIND_SQL}${except}
|
|
588
|
+
ORDER BY created_at DESC`,
|
|
589
|
+
...params,
|
|
590
|
+
).map((r) => ({ token_id: r.token_id, name: r.name, client_label: r.client_label ?? null, created_at: r.created_at, last_used_at: r.last_used_at ?? null }));
|
|
591
|
+
return { login, cli: 0, kept };
|
|
592
|
+
}
|
|
593
|
+
|
|
533
594
|
export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
|
|
534
595
|
const result = exceptTokenId
|
|
535
596
|
? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
|
|
@@ -544,6 +605,7 @@ export type LoginSession = {
|
|
|
544
605
|
last_used_at: string | null;
|
|
545
606
|
client_label: string | null;
|
|
546
607
|
user_agent: string | null;
|
|
608
|
+
kind: UserTokenKind;
|
|
547
609
|
is_current: boolean;
|
|
548
610
|
};
|
|
549
611
|
|
|
@@ -551,7 +613,7 @@ export type LoginSession = {
|
|
|
551
613
|
export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
|
|
552
614
|
const cutoff = idleCutoffOffset();
|
|
553
615
|
const rows = db.all<any>(
|
|
554
|
-
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
|
|
616
|
+
`SELECT token_id, name, created_at, last_used_at, client_label, user_agent, kind FROM api_tokens
|
|
555
617
|
WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
|
|
556
618
|
AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
|
|
557
619
|
ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
|
|
@@ -563,6 +625,7 @@ export function listSessions(userId: string, currentTokenId: string | null): Log
|
|
|
563
625
|
last_used_at: r.last_used_at ?? null,
|
|
564
626
|
client_label: r.client_label ?? null,
|
|
565
627
|
user_agent: r.user_agent ?? null,
|
|
628
|
+
kind: r.kind === "cli" ? "cli" : "login",
|
|
566
629
|
is_current: !!currentTokenId && r.token_id === currentTokenId,
|
|
567
630
|
}));
|
|
568
631
|
}
|
|
@@ -578,7 +641,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
|
|
|
578
641
|
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
579
642
|
}
|
|
580
643
|
|
|
581
|
-
export
|
|
644
|
+
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
|
|
648
|
+
* 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
|
|
649
|
+
* revoked = 两类合计(旧字段,兼容旧客户端)。
|
|
650
|
+
*/
|
|
651
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
|
|
582
652
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
583
653
|
if (passwordError) return { ok: false, error: passwordError };
|
|
584
654
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
@@ -593,8 +663,9 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
593
663
|
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
594
664
|
// so a future flag flip can't drift the state.
|
|
595
665
|
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
596
|
-
const
|
|
597
|
-
|
|
666
|
+
const keep = opts.keepCliTokens === true;
|
|
667
|
+
const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
|
|
668
|
+
return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
|
|
598
669
|
}
|
|
599
670
|
|
|
600
671
|
/**
|
|
@@ -886,7 +957,7 @@ const NETWORK_CLEANUP_TABLES = [
|
|
|
886
957
|
"network_members", "network_member_agent_grants", "network_invites", "api_tokens",
|
|
887
958
|
"node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
|
|
888
959
|
"node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
|
|
889
|
-
"task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
|
|
960
|
+
"task_events", "task_terminal_events", "task_late_replies", "scheduled_task_runs", "external_schedule_edits",
|
|
890
961
|
// 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
|
|
891
962
|
"network_tags",
|
|
892
963
|
// 组织架构(board #419):部门是网络本身的元数据。
|
package/src/db.ts
CHANGED
|
@@ -646,12 +646,26 @@ for (const ddl of [
|
|
|
646
646
|
// 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
|
|
647
647
|
"ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
|
|
648
648
|
"ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
|
|
649
|
+
// #711 不绑网络的用户令牌分两类:'login' = 浏览器 / app 的登录会话;'cli' = anet 命令行登录、
|
|
650
|
+
// POST /api/auth/tokens 建的具名脚本令牌。只用于展示和 keep_cli_tokens 那条显式保留路径;节点 / 网络令牌(network_id 非空)不填。
|
|
651
|
+
"ALTER TABLE api_tokens ADD COLUMN kind TEXT",
|
|
649
652
|
]) {
|
|
650
653
|
try { db.exec(ddl); }
|
|
651
654
|
catch (e: any) {
|
|
652
655
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
// #711 回填 kind:只碰 kind 还是 NULL 的非网络令牌(幂等;回滚到旧 Hub 期间新签的行,下次启动照样补上)。
|
|
659
|
+
// 能明确认出是 anet 命令行的才算 'cli':scope='full'(POST /api/auth/tokens 建的具名令牌),
|
|
660
|
+
// 或 client_label 去掉首尾空格后以「anet 」开头(anet 自报的「anet <版本> · <主机> · <子命令>」)。其余一律 'login'。
|
|
661
|
+
// 用 SUBSTR + = 而不是 LIKE:SQLite 的 LIKE 不分大小写、PG 分,两边结果会不一样;= 两边都区分大小写,
|
|
662
|
+
// 与运行时 isAnetClientLabel(auth.ts)同一条规则。
|
|
663
|
+
db.run(
|
|
664
|
+
`UPDATE api_tokens SET kind = CASE
|
|
665
|
+
WHEN scope = 'full' OR SUBSTR(TRIM(client_label), 1, 5) = 'anet ' THEN 'cli'
|
|
666
|
+
ELSE 'login' END
|
|
667
|
+
WHERE network_id IS NULL AND kind IS NULL`,
|
|
668
|
+
);
|
|
655
669
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
656
670
|
catch (e: any) { /* index may already exist */ void e; }
|
|
657
671
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
|
|
@@ -969,7 +983,7 @@ db.exec(`
|
|
|
969
983
|
requirement_id TEXT PRIMARY KEY,
|
|
970
984
|
network_id TEXT NOT NULL,
|
|
971
985
|
title TEXT NOT NULL,
|
|
972
|
-
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
986
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned')),
|
|
973
987
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
|
|
974
988
|
due_on TEXT,
|
|
975
989
|
assignee TEXT,
|
|
@@ -1475,6 +1489,31 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
|
1475
1489
|
try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
|
|
1476
1490
|
try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
|
|
1477
1491
|
|
|
1492
|
+
// Board #710 — a result that arrives after the task is already terminal is
|
|
1493
|
+
// evidence about that exact runtime turn, not a second task transition. Keep
|
|
1494
|
+
// it in an additive table: old clients and old Hub code continue to read the
|
|
1495
|
+
// tasks row unchanged, while new detail readers can project `late_replies`.
|
|
1496
|
+
db.exec(`
|
|
1497
|
+
CREATE TABLE IF NOT EXISTS task_late_replies (
|
|
1498
|
+
late_reply_id TEXT PRIMARY KEY,
|
|
1499
|
+
inbox_id TEXT NOT NULL UNIQUE,
|
|
1500
|
+
task_id TEXT NOT NULL,
|
|
1501
|
+
network_id TEXT,
|
|
1502
|
+
from_node_id TEXT NOT NULL,
|
|
1503
|
+
thread_id TEXT NOT NULL,
|
|
1504
|
+
turn_id TEXT NOT NULL,
|
|
1505
|
+
status TEXT NOT NULL CHECK (status IN ('replied','failed','cancelled')),
|
|
1506
|
+
result TEXT NOT NULL,
|
|
1507
|
+
meta_json TEXT,
|
|
1508
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1509
|
+
UNIQUE (task_id, thread_id, turn_id)
|
|
1510
|
+
);
|
|
1511
|
+
CREATE INDEX IF NOT EXISTS idx_task_late_replies_task_time
|
|
1512
|
+
ON task_late_replies(task_id, created_at);
|
|
1513
|
+
CREATE INDEX IF NOT EXISTS idx_task_late_replies_network
|
|
1514
|
+
ON task_late_replies(network_id, created_at);
|
|
1515
|
+
`);
|
|
1516
|
+
|
|
1478
1517
|
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1479
1518
|
// as a recovery watermark because an old task may become terminal after newer
|
|
1480
1519
|
// tasks. Record terminal transitions in their own monotonically increasing
|
|
@@ -1599,6 +1638,10 @@ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NU
|
|
|
1599
1638
|
// whether the node had ever picked it up: not_received | in_progress.
|
|
1600
1639
|
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
|
|
1601
1640
|
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
|
|
1641
|
+
// Board #733 — schedules an Agent created over MCP name the creating node. NULL = created by a person
|
|
1642
|
+
// (REST / Dashboard) or a row from before this column; old rows keep NULL and old code never reads it.
|
|
1643
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN created_by_node_id TEXT"); } catch {}
|
|
1644
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_creator_node ON scheduled_tasks(network_id, created_by_node_id)"); } catch {}
|
|
1602
1645
|
// #523 — consecutive-failure alert bookkeeping (scheduled-failures.ts). Additive and nullable:
|
|
1603
1646
|
// an older Hub never reads them, so rolling back is safe. failure_alert_at = ISO time of the last
|
|
1604
1647
|
// alert; failure_alert_key = the last successful run_id at that moment ('' = none yet), so a
|
package/src/node-permissions.ts
CHANGED
|
@@ -244,7 +244,7 @@ export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): V
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
247
|
-
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "node_write" | "human_only";
|
|
247
|
+
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "schedule" | "node_write" | "human_only";
|
|
248
248
|
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
249
249
|
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
250
250
|
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
@@ -268,6 +268,9 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
268
268
|
requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
|
|
269
269
|
requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
|
|
270
270
|
requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
|
|
271
|
+
// 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
|
|
272
|
+
schedule_list: "read", schedule_get: "read", schedule_runs: "read",
|
|
273
|
+
schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
|
|
271
274
|
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
272
275
|
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
|
|
273
276
|
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
@@ -227,7 +227,7 @@ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdu
|
|
|
227
227
|
export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
|
|
228
228
|
const today = localDate(nowMs, tz);
|
|
229
229
|
const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
230
|
-
return `(due_on IS NOT NULL AND column_name
|
|
230
|
+
return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
|
|
231
231
|
}
|
|
232
232
|
/** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
|
|
233
233
|
export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
|
|
@@ -235,7 +235,7 @@ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz:
|
|
|
235
235
|
const lastDay = addDays(today, days);
|
|
236
236
|
const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
237
237
|
const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
238
|
-
return `(due_on IS NOT NULL AND column_name
|
|
238
|
+
return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
// ── 扫描 ───────────────────────────────────────────────────
|
|
@@ -332,7 +332,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
332
332
|
const rows = db.all<DueRow>(
|
|
333
333
|
`SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
|
|
334
334
|
FROM requirements
|
|
335
|
-
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name
|
|
335
|
+
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name NOT IN ('done', 'abandoned') AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
|
|
336
336
|
...params,
|
|
337
337
|
);
|
|
338
338
|
const out: DueReminderSent[] = [];
|
|
@@ -12,7 +12,7 @@ import { isParticipant } from "./task-access.js";
|
|
|
12
12
|
import { rewriteUnreadNoticeDm, sendNoticeDm } from "./human-dm.js";
|
|
13
13
|
|
|
14
14
|
export const COALESCE_WINDOW_MS = 60_000;
|
|
15
|
-
const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成" };
|
|
15
|
+
const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成", abandoned: "废弃" };
|
|
16
16
|
const MAX_LINES = 20;
|
|
17
17
|
const MAX_ITEM_CHARS = 40;
|
|
18
18
|
|