@sleep2agi/commhub-server 0.9.0-preview.134 → 0.9.0-preview.136
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 +2 -2
- package/src/auth-password-change-cli-tokens-http.test.ts +131 -2
- package/src/auth.ts +13 -9
- package/src/db.ts +3 -0
- package/src/node-caller-identity-http.test.ts +97 -0
- package/src/password-network-tokens.ts +25 -0
- package/src/scheduled-stuck-http.test.ts +24 -0
- package/src/scheduled-stuck.ts +8 -1
- package/src/scheduled-tasks.ts +2 -2
- package/src/server.ts +1 -1
- package/src/task-activity.ts +60 -0
- package/src/tools.ts +6 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
4
|
-
"description": "CommHub Server
|
|
3
|
+
"version": "0.9.0-preview.136",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
|
|
7
7
|
// · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
|
|
8
8
|
// · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
|
|
9
|
-
// ·
|
|
9
|
+
// · 节点令牌不动;具名网络令牌默认保留,可显式revoke_network_tokens;
|
|
10
10
|
// · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
|
|
11
11
|
// SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@ import { mkdtempSync, rmSync } from "fs";
|
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { tmpdir } from "os";
|
|
17
17
|
import { spawnSync } from "child_process";
|
|
18
|
-
import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
|
|
18
|
+
import { register, createNetworkTokenForNode, createToken, createNetwork, changePassword } from "./auth.js";
|
|
19
19
|
import { db, hashToken } from "./db.js";
|
|
20
20
|
|
|
21
21
|
const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
|
|
@@ -292,3 +292,132 @@ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
|
|
|
292
292
|
expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
|
|
293
293
|
}, 120_000);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
describe("network password opt-in", () => {
|
|
298
|
+
const id = (token: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash=?1", hashToken(token)).token_id;
|
|
299
|
+
|
|
300
|
+
test("default lists only the caller's active named network tokens across networks, without credentials", async () => {
|
|
301
|
+
const f = await fleet();
|
|
302
|
+
const second = createNetwork(f.userId, `pwk-second-${seq}`);
|
|
303
|
+
expect(second.ok).toBe(true);
|
|
304
|
+
const extra = createToken(f.userId, "second-network-script", second.network_id);
|
|
305
|
+
const other = await fleet();
|
|
306
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
307
|
+
expect(r.status).toBe(200);
|
|
308
|
+
expect(r.body.revoked_network).toBe(0);
|
|
309
|
+
expect(r.body.revoked).toBe(5);
|
|
310
|
+
expect(r.body.kept_network_tokens.map((x: any) => x.token_id).sort()).toEqual([id(f.netToken), id(extra.token!)].sort());
|
|
311
|
+
for (const row of r.body.kept_network_tokens) {
|
|
312
|
+
expect(Object.keys(row).sort()).toEqual(["created_at", "last_used_at", "name", "network_id", "token_id"]);
|
|
313
|
+
}
|
|
314
|
+
const body = JSON.stringify(r.body);
|
|
315
|
+
expect(body.includes("token_hash")).toBe(false);
|
|
316
|
+
for (const t of [f.netToken, extra.token!, f.nodeToken, other.netToken]) expect(body.includes(t)).toBe(false);
|
|
317
|
+
expect(await works(f.netToken)).toBe(true);
|
|
318
|
+
expect(await works(extra.token!)).toBe(true);
|
|
319
|
+
expect(await works(other.netToken)).toBe(true);
|
|
320
|
+
expect(await works(r.body.token)).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("explicit boolean opt-in revokes only own named network tokens, independently of keep_cli_tokens", async () => {
|
|
324
|
+
const f = await fleet();
|
|
325
|
+
const other = await fleet();
|
|
326
|
+
const second = createNetwork(f.userId, `pwk-revoke-second-${seq}`);
|
|
327
|
+
const extra = createToken(f.userId, "second-network-script", second.network_id);
|
|
328
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true, revoke_network_tokens: true });
|
|
329
|
+
expect(r.status).toBe(200);
|
|
330
|
+
expect(r.body.revoked_network).toBe(2);
|
|
331
|
+
expect(r.body.revoked_login).toBe(2);
|
|
332
|
+
expect(r.body.revoked_cli).toBe(0);
|
|
333
|
+
expect(r.body.revoked).toBe(4);
|
|
334
|
+
expect(r.body.kept_network_tokens).toEqual([]);
|
|
335
|
+
expect(await works(f.netToken)).toBe(false);
|
|
336
|
+
expect(await works(extra.token!)).toBe(false);
|
|
337
|
+
expect(await works(other.netToken)).toBe(true);
|
|
338
|
+
expect(await works(other.app)).toBe(true);
|
|
339
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
340
|
+
expect(await works(f.apiToken)).toBe(true);
|
|
341
|
+
expect(await works(r.body.token)).toBe(true);
|
|
342
|
+
expect(await works(f.app)).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
for (const value of [false, "true", 1, null, {}]) {
|
|
346
|
+
test(`non-true option ${JSON.stringify(value)} cannot revoke network tokens`, async () => {
|
|
347
|
+
const f = await fleet();
|
|
348
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: value });
|
|
349
|
+
expect(r.status).toBe(200);
|
|
350
|
+
expect(r.body.revoked_network).toBe(0);
|
|
351
|
+
expect(r.body.kept_network_tokens).toHaveLength(1);
|
|
352
|
+
expect(await works(f.netToken)).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
test("scope and binding protect node credentials; inactive rows are neither listed nor counted", async () => {
|
|
357
|
+
const f = await fleet();
|
|
358
|
+
const legacy = createNetworkTokenForNode(f.userId, f.net, `legacy-pwk-${seq}`);
|
|
359
|
+
expect(legacy.ok).toBe(true);
|
|
360
|
+
const bound = createToken(f.userId, "mislabelled-bound-node", f.net);
|
|
361
|
+
db.run("UPDATE api_tokens SET bound_node_id=?1 WHERE token_id=?2", ["n-protected717", bound.token_id]);
|
|
362
|
+
const expired = createToken(f.userId, "expired-script", f.net);
|
|
363
|
+
db.run("UPDATE api_tokens SET expires_at=datetime('now','-1 day') WHERE token_id=?1", [expired.token_id]);
|
|
364
|
+
const revoked = createToken(f.userId, "revoked-script", f.net);
|
|
365
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", [revoked.token_id]);
|
|
366
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true });
|
|
367
|
+
expect(r.status).toBe(200);
|
|
368
|
+
expect(r.body.revoked_network).toBe(1);
|
|
369
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
370
|
+
expect(await works(bound.token!)).toBe(true);
|
|
371
|
+
for (const t of [bound, expired, revoked, legacy]) expect(db.get("SELECT token_id FROM api_tokens WHERE token_id=?1", t.token_id)).not.toBeNull();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("bad old password or weak new password has no token side effects", async () => {
|
|
375
|
+
for (const body of [{ old_password: "wrong", new_password: PW2 }, { old_password: PW, new_password: "123" }]) {
|
|
376
|
+
const f = await fleet();
|
|
377
|
+
const r = await call(f.app, "POST", "/api/auth/password", { ...body, revoke_network_tokens: true });
|
|
378
|
+
expect(r.status).toBe(400);
|
|
379
|
+
for (const t of [f.app, f.netToken, f.apiToken, f.nodeToken]) expect(await works(t)).toBe(true);
|
|
380
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("network-scoped and unauthenticated credentials cannot invoke password change", async () => {
|
|
385
|
+
const f = await fleet();
|
|
386
|
+
for (const t of [f.netToken, f.nodeToken, ""]) {
|
|
387
|
+
expect((await call(t, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(t ? 403 : 401);
|
|
388
|
+
}
|
|
389
|
+
expect(await works(f.netToken)).toBe(true);
|
|
390
|
+
expect(await works(f.app)).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a failed network revoke rolls back the password and earlier session deletions", async () => {
|
|
394
|
+
const f = await fleet();
|
|
395
|
+
const victim = id(f.netToken);
|
|
396
|
+
const fn = `pw717_failure_${seq}`;
|
|
397
|
+
const trigger = `pw717_trigger_${seq}`;
|
|
398
|
+
if (db.dialect === "postgres") db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.token_id='${victim}' THEN RAISE EXCEPTION 'fixture717_delete_failure'; END IF; RETURN OLD; END; $$; CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
|
|
399
|
+
else db.exec(`CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens WHEN OLD.token_id='${victim}' BEGIN SELECT RAISE(ABORT,'fixture717_delete_failure'); END`);
|
|
400
|
+
try {
|
|
401
|
+
expect((await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(400);
|
|
402
|
+
for (const t of [f.app, f.appB, f.apiToken, f.netToken, f.nodeToken]) expect(await works(t)).toBe(true);
|
|
403
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
|
|
404
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW2 })).status).toBe(401);
|
|
405
|
+
} finally {
|
|
406
|
+
db.exec(db.dialect === "postgres" ? `DROP TRIGGER ${trigger} ON api_tokens; DROP FUNCTION ${fn}()` : `DROP TRIGGER ${trigger}`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("direct changePassword keeps its current row and defaults to listing network tokens", async () => {
|
|
411
|
+
const f = await fleet();
|
|
412
|
+
const cur = id(f.app);
|
|
413
|
+
const r = changePassword(f.userId, PW, PW2, cur);
|
|
414
|
+
expect(r.ok).toBe(true);
|
|
415
|
+
expect(r.kept_network_tokens).toHaveLength(1);
|
|
416
|
+
expect(await works(f.app)).toBe(true);
|
|
417
|
+
const r2 = changePassword(f.userId, PW2, PW, cur, { revokeNetworkTokens: true, keepCliTokens: true });
|
|
418
|
+
expect(r2.ok).toBe(true);
|
|
419
|
+
expect(r2.revoked_network).toBe(1);
|
|
420
|
+
expect(await works(f.app)).toBe(true);
|
|
421
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* V3 Auth module — user registration, login, token management
|
|
3
3
|
*/
|
|
4
4
|
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
|
|
5
|
-
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
5
|
+
import { WEAK_PASSWORDS } from "./password-dict.js"; import { passwordNetworkTokens, type KeptNetworkToken } from "./password-network-tokens.js";
|
|
6
6
|
import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
|
|
7
7
|
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
8
8
|
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
@@ -330,7 +330,7 @@ export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: u
|
|
|
330
330
|
// 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
|
|
331
331
|
// 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
|
|
332
332
|
// 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
|
|
333
|
-
// 节点 / 网络令牌(network_id 非空)
|
|
333
|
+
// 节点 / 网络令牌(network_id 非空)不按cli分类;具名网络令牌另有显式撤销选项。
|
|
334
334
|
export type UserTokenKind = "login" | "cli";
|
|
335
335
|
|
|
336
336
|
/** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
|
|
@@ -642,14 +642,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
|
|
|
642
642
|
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
-
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
|
|
645
|
+
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[]; revoked_network?: number; kept_network_tokens?: KeptNetworkToken[] };
|
|
646
646
|
|
|
647
647
|
/**
|
|
648
648
|
* 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
|
|
649
649
|
* 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
|
|
650
|
-
* revoked
|
|
650
|
+
* revokeNetworkTokens=true显式撤销具名网络令牌;默认列出保留清单。revoked含三类合计。
|
|
651
651
|
*/
|
|
652
|
-
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
|
|
652
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean; revokeNetworkTokens?: boolean } = {}): ChangePasswordResult {
|
|
653
653
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
654
654
|
if (passwordError) return { ok: false, error: passwordError };
|
|
655
655
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
@@ -663,10 +663,14 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
663
663
|
// password change. If the user changes via `anet passwd`, the bootstrap
|
|
664
664
|
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
665
665
|
// so a future flag flip can't drift the state.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
666
|
+
const nextHash = hashPassword(newPassword);
|
|
667
|
+
return db.transaction(() => {
|
|
668
|
+
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [nextHash, userId]);
|
|
669
|
+
const keep = opts.keepCliTokens === true;
|
|
670
|
+
const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
|
|
671
|
+
const network = passwordNetworkTokens(userId, currentTokenId, opts.revokeNetworkTokens === true);
|
|
672
|
+
return { ok: true, revoked: r.login + r.cli + network.revoked, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}), revoked_network: network.revoked, kept_network_tokens: network.kept };
|
|
673
|
+
});
|
|
670
674
|
}
|
|
671
675
|
|
|
672
676
|
/**
|
package/src/db.ts
CHANGED
|
@@ -245,6 +245,7 @@ db.exec(`
|
|
|
245
245
|
started_at TEXT,
|
|
246
246
|
runtime_submitted_at TEXT,
|
|
247
247
|
consumed_at TEXT,
|
|
248
|
+
last_activity_at TEXT,
|
|
248
249
|
thread_id TEXT,
|
|
249
250
|
turn_id TEXT,
|
|
250
251
|
completed_at TEXT,
|
|
@@ -1557,6 +1558,8 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
|
1557
1558
|
// started_at (legacy report_status/content matching). Old binaries ignore them.
|
|
1558
1559
|
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1559
1560
|
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1561
|
+
// #1113: exact original-turn activity. NULL keeps the old execution-clock fallback.
|
|
1562
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN last_activity_at TEXT"); } catch {}
|
|
1560
1563
|
// Exact vendor-runtime boundary for SideThread-capable clients. These values
|
|
1561
1564
|
// are written only by the token-bound target node after attributable runtime
|
|
1562
1565
|
// evidence; the Hub never derives them from task text or caller input.
|
|
@@ -28,6 +28,7 @@ import { join } from "node:path";
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
30
30
|
import { db, generateNetworkToken, hashToken } from "./db.js";
|
|
31
|
+
import { createTaskRuntimeEvidenceReporter, taskActivityPayload } from "../../agent-node/src/task-runtime-evidence";
|
|
31
32
|
|
|
32
33
|
const dir = mkdtempSync(join(tmpdir(), "anet-caller-identity-"));
|
|
33
34
|
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
@@ -984,3 +985,99 @@ test("duplicate ownerless row keeps an unbound epoch 0 token online", async () =
|
|
|
984
985
|
first.abort();
|
|
985
986
|
second.abort();
|
|
986
987
|
}, 15_000);
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
const leaseAlias = `leasebase${stamp}`, leaseId = `n_leasebase_${stamp}`;
|
|
991
|
+
let leaseTok: Tok;
|
|
992
|
+
beforeAll(() => { seedNode(leaseId,leaseAlias,NET,ownerId);seedSession(leaseId,leaseAlias,NET);leaseTok=mint(ownerId,NET,leaseAlias,2,leaseId); });
|
|
993
|
+
|
|
994
|
+
function activityTask(nodeId: string, alias: string, network = NET) {
|
|
995
|
+
const id = `task_activity_${crypto.randomUUID()}`;
|
|
996
|
+
db.run("INSERT INTO tasks (task_id, from_name, to_node_id, to_name, status, content, network_id) VALUES (?1, 'scheduler', ?2, ?3, 'running', 'activity fixture', ?4)", [id, nodeId, alias, network]);
|
|
997
|
+
return { task_id: id, thread_id: `thread_${id}`, turn_id: `turn_${id}` };
|
|
998
|
+
}
|
|
999
|
+
const activitySnap = (id: string) => db.get<any>("SELECT status, consumed_at, runtime_submitted_at, started_at, thread_id, turn_id, last_activity_at FROM tasks WHERE task_id = ?1", id);
|
|
1000
|
+
async function consumeActivityTask(context: {task_id: string; thread_id: string; turn_id: string}) {
|
|
1001
|
+
expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [context.task_id], task_contexts: [context] })).ok).toBe(true);
|
|
1002
|
+
}
|
|
1003
|
+
const renewActivity = (token: string, contexts: Array<{task_id: string; thread_id: string; turn_id: string}>) => tool(token, "mark_tasks_consumed", { task_ids: contexts.map(c => c.task_id), task_contexts: contexts, activity_only: true });
|
|
1004
|
+
|
|
1005
|
+
test("activity lease: exact task renewal changes only the activity clock; duplicate consumption does not renew", async () => {
|
|
1006
|
+
const c = activityTask(leaseId, leaseAlias); await consumeActivityTask(c);
|
|
1007
|
+
db.run("UPDATE tasks SET last_activity_at = datetime('now', '-2 hours') WHERE task_id = ?1", [c.task_id]);
|
|
1008
|
+
const before = activitySnap(c.task_id);
|
|
1009
|
+
expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [c.task_id], task_contexts: [c] })).ok).toBe(true);
|
|
1010
|
+
expect(activitySnap(c.task_id)).toEqual(before);
|
|
1011
|
+
const out = await renewActivity(leaseTok.token, [c]); expect(out.ok).toBe(true);
|
|
1012
|
+
const after = activitySnap(c.task_id);
|
|
1013
|
+
expect(after.last_activity_at).not.toBe(before.last_activity_at);
|
|
1014
|
+
expect({...after, last_activity_at: before.last_activity_at}).toEqual(before);
|
|
1015
|
+
expect(out.tasks[0].last_activity_at).toBe(after.last_activity_at);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test("activity lease: bound node wins over a same-name session and cannot renew the other node", async () => {
|
|
1019
|
+
const alias = `leasebound${stamp}`, mine = `n_leasebound_${stamp}`, other = `n_leaseother_${stamp}`;
|
|
1020
|
+
seedNode(mine, alias, NET, ownerId); seedSession(mine, alias, NET);
|
|
1021
|
+
const tok = mint(ownerId, NET, leaseAlias, 2, mine), own = activityTask(mine, alias);
|
|
1022
|
+
expect((await tool(tok.token, "mark_tasks_consumed", {task_ids: [own.task_id], task_contexts: [own]})).ok).toBe(true);
|
|
1023
|
+
seedNode(other, alias, NET, ownerId); db.run("UPDATE sessions SET node_id=?1 WHERE alias=?2 AND network_id=?3",[other,alias,NET]);
|
|
1024
|
+
expect((await renewActivity(tok.token, [own])).ok).toBe(true);
|
|
1025
|
+
const foreign = activityTask(other, alias);
|
|
1026
|
+
db.run("UPDATE tasks SET consumed_at=datetime('now'),thread_id=?1,turn_id=?2 WHERE task_id=?3", [foreign.thread_id,foreign.turn_id,foreign.task_id]);
|
|
1027
|
+
expect((await renewActivity(tok.token,[foreign])).error).toBe("task_not_owned");
|
|
1028
|
+
expect(activitySnap(foreign.task_id).last_activity_at).toBeNull();
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
for (const shape of ["wrong thread", "wrong turn", "foreign node", "foreign network", "legacy null node", "not consumed", "missing context", "user token"] as const) {
|
|
1032
|
+
test(`activity lease: rejects ${shape} without changing the task`, async () => {
|
|
1033
|
+
const c = activityTask(leaseId, leaseAlias); if(shape !== "not consumed") await consumeActivityTask(c);
|
|
1034
|
+
if(shape === "foreign node") db.run("UPDATE tasks SET to_node_id=?1 WHERE task_id=?2", [otherId,c.task_id]);
|
|
1035
|
+
if(shape === "foreign network") db.run("UPDATE tasks SET network_id=?1 WHERE task_id=?2", [NET2,c.task_id]);
|
|
1036
|
+
if(shape === "legacy null node") db.run("UPDATE tasks SET to_node_id=NULL WHERE task_id=?1",[c.task_id]);
|
|
1037
|
+
const before=activitySnap(c.task_id); const context={...c};
|
|
1038
|
+
if(shape === "wrong thread") context.thread_id="sibling_thread";
|
|
1039
|
+
if(shape === "wrong turn") context.turn_id="sibling_turn";
|
|
1040
|
+
const out=await tool(shape === "user token" ? ownerToken : leaseTok.token,"mark_tasks_consumed",{task_ids:[c.task_id],task_contexts:shape === "missing context" ? [] : [context],activity_only:true});
|
|
1041
|
+
expect(out.ok).toBe(false); expect(activitySnap(c.task_id)).toEqual(before);
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
for(const status of ["replied","failed","cancelled","expired"]){
|
|
1045
|
+
test(`activity lease: ${status} terminal never receives activity or reopens`,async()=>{
|
|
1046
|
+
const c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);db.run("UPDATE tasks SET status=?1 WHERE task_id=?2",[status,c.task_id]);const before=activitySnap(c.task_id);
|
|
1047
|
+
expect((await renewActivity(leaseTok.token,[c])).error).toBe("task_activity_terminal");expect(activitySnap(c.task_id)).toEqual(before);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
test("activity lease: conflicting batch is rejected with zero partial writes",async()=>{
|
|
1051
|
+
const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
|
|
1052
|
+
const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);
|
|
1053
|
+
expect((await renewActivity(leaseTok.token,[first,{...second,turn_id:"old_turn"}])).ok).toBe(false);
|
|
1054
|
+
expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test("activity lease: injected second-write failure rolls back the first write on the real backend",async()=>{
|
|
1058
|
+
const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
|
|
1059
|
+
const trigger=`activity_fail_${stamp}`,fn=`activity_fn_${stamp}`;
|
|
1060
|
+
if(db.dialect === "postgres")db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.task_id='${second.task_id}' THEN RAISE EXCEPTION 'test1113_injected_activity_failure'; END IF; RETURN NEW; END; $$; CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
|
|
1061
|
+
else db.exec(`CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks WHEN NEW.task_id='${second.task_id}' BEGIN SELECT RAISE(ABORT,'test1113_injected_activity_failure'); END`);
|
|
1062
|
+
const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);let failed=false;
|
|
1063
|
+
try{await renewActivity(leaseTok.token,[first,second]);}catch(e){failed=String(e).includes("test1113_injected_activity_failure");}
|
|
1064
|
+
finally{if(db.dialect === "postgres")db.exec(`DROP TRIGGER ${trigger} ON tasks; DROP FUNCTION ${fn}()`);else db.exec(`DROP TRIGGER ${trigger}`);}
|
|
1065
|
+
expect(failed).toBe(true);expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
|
|
1066
|
+
});
|
|
1067
|
+
test("activity lease: revoked credential cannot report activity",async()=>{
|
|
1068
|
+
const tok=mint(ownerId,NET,leaseAlias,2,leaseId),c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);const before=activitySnap(c.task_id);
|
|
1069
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1",[tok.tokenId]);
|
|
1070
|
+
const response=await fetch(`${base}/mcp`,{method:"POST",headers:{Authorization:`Bearer ${tok.token}`,"Content-Type":"application/json",Accept:"application/json, text/event-stream"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"mark_tasks_consumed",arguments:{task_ids:[c.task_id],task_contexts:[c],activity_only:true}}})});
|
|
1071
|
+
expect(response.status).toBe(401);expect(activitySnap(c.task_id)).toEqual(before);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("activity lease: real runtime reporter payload traverses authenticated HTTP into the exact task clock",async()=>{
|
|
1075
|
+
const c=activityTask(leaseId,leaseAlias);let clock=0;const errors:string[]=[];
|
|
1076
|
+
const reporter=createTaskRuntimeEvidenceReporter({taskId:c.task_id,now:()=>clock,
|
|
1077
|
+
report:(_level,id,context)=>tool(leaseTok.token,"mark_tasks_consumed",{task_ids:[id],task_contexts:[{task_id:id,thread_id:context!.threadId,turn_id:context!.turnId}]}),
|
|
1078
|
+
reportActivity:(id,context)=>tool(leaseTok.token,"mark_tasks_consumed",taskActivityPayload(id,context)),debug:message=>errors.push(message)});
|
|
1079
|
+
reporter.consumed({threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).consumed_at,"consumption stamped");
|
|
1080
|
+
const before=activitySnap(c.task_id);clock=30_000;reporter.activity({taskId:"other_task",threadId:c.thread_id,turnId:c.turn_id});await new Promise(resolve=>setTimeout(resolve,5));expect(activitySnap(c.task_id)).toEqual(before);
|
|
1081
|
+
reporter.activity({taskId:c.task_id,threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).last_activity_at,"runtime activity stamped");
|
|
1082
|
+
expect(errors).toEqual([]);expect(activitySnap(c.task_id).consumed_at).toBe(before.consumed_at);expect(activitySnap(c.task_id).status).toBe("running");
|
|
1083
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
export type KeptNetworkToken = {
|
|
4
|
+
token_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
network_id: string;
|
|
7
|
+
created_at: string;
|
|
8
|
+
last_used_at: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Only user-created named network tokens. Node credentials have a different scope or binding. */
|
|
12
|
+
export function passwordNetworkTokens(userId: string, currentTokenId: string | null | undefined, revoke: boolean) {
|
|
13
|
+
const where = `user_id = ?1 AND network_id IS NOT NULL AND scope = 'full'
|
|
14
|
+
AND bound_node_id IS NULL AND revoked_at IS NULL
|
|
15
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))${currentTokenId ? " AND token_id != ?2" : ""}`;
|
|
16
|
+
const params = currentTokenId ? [userId, currentTokenId] : [userId];
|
|
17
|
+
if (revoke) {
|
|
18
|
+
return { revoked: db.run(`DELETE FROM api_tokens WHERE ${where}`, params).changes, kept: [] as KeptNetworkToken[] };
|
|
19
|
+
}
|
|
20
|
+
const kept = db.all<KeptNetworkToken>(
|
|
21
|
+
`SELECT token_id, name, network_id, created_at, last_used_at FROM api_tokens WHERE ${where} ORDER BY created_at DESC, token_id`,
|
|
22
|
+
...params,
|
|
23
|
+
);
|
|
24
|
+
return { revoked: 0, kept };
|
|
25
|
+
}
|
|
@@ -286,3 +286,27 @@ describe("#1112 execution clock", () => {
|
|
|
286
286
|
expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:00" }, schedule, now)).toBe(true);
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
describe("#1113 activity idle clock",()=>{
|
|
292
|
+
test("fresh exact-task activity protects a long consumed turn, silent activity still expires",()=>{
|
|
293
|
+
const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
|
|
294
|
+
db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(180),at(120),at(1),first.taskId]);
|
|
295
|
+
expect(fire(s,now).status).toBe("skipped");expect(taskRow(first.taskId!).status).toBe("running");
|
|
296
|
+
db.run("UPDATE tasks SET last_activity_at=?1 WHERE task_id=?2",[at(61),first.taskId]);expect(fire(s,now).status).toBe("delivered");
|
|
297
|
+
expect(runRow(first.runId).error_message).toContain("since last_activity_at");
|
|
298
|
+
});
|
|
299
|
+
test("fresh activity cannot evade the absolute total cap",()=>{
|
|
300
|
+
const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
|
|
301
|
+
db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(1441),at(1440),at(1),first.taskId]);
|
|
302
|
+
expect(fire(s,now).status).toBe("delivered");expect(runRow(first.runId).error_message).toContain("total cap");
|
|
303
|
+
});
|
|
304
|
+
test("activity without consumption, before consumption or in the future never renews",()=>{
|
|
305
|
+
const now=new Date("2026-10-13T04:00:00Z"),schedule=JSON.stringify({type:"interval",every_seconds:60});
|
|
306
|
+
for(const row of [
|
|
307
|
+
{created_at:"2026-10-13 01:00:00",last_activity_at:"2026-10-13 03:59:00"},
|
|
308
|
+
{created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 01:59:00"},
|
|
309
|
+
{created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 04:01:00"},
|
|
310
|
+
])expect(blockerTimedOut(row,schedule,now)).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
package/src/scheduled-stuck.ts
CHANGED
|
@@ -59,6 +59,7 @@ export function stuckTimeoutMs(scheduleJson: string): number {
|
|
|
59
59
|
export type StuckBlockerClock = {
|
|
60
60
|
created_at: string;
|
|
61
61
|
consumed_at?: string | null;
|
|
62
|
+
last_activity_at?: string | null;
|
|
62
63
|
started_at?: string | null;
|
|
63
64
|
};
|
|
64
65
|
|
|
@@ -77,6 +78,12 @@ export function blockerTimeoutClock(blocker: StuckBlockerClock, scheduleJson: st
|
|
|
77
78
|
if (!value) continue;
|
|
78
79
|
const at = parseDbTimestampMs(value);
|
|
79
80
|
if (Number.isFinite(at) && at <= nowMs && (!Number.isFinite(created) || at >= created)) {
|
|
81
|
+
if (source === "consumed_at" && blocker.last_activity_at) {
|
|
82
|
+
const activity = parseDbTimestampMs(blocker.last_activity_at);
|
|
83
|
+
if (Number.isFinite(activity) && activity >= at && activity <= nowMs) {
|
|
84
|
+
return { ageMs: nowMs - activity, limitMs: stuckTimeoutMs(scheduleJson), source: "last_activity_at" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
80
87
|
return { ageMs: nowMs - at, limitMs: stuckTimeoutMs(scheduleJson), source };
|
|
81
88
|
}
|
|
82
89
|
}
|
|
@@ -153,7 +160,7 @@ export function stuckNoticeText(n: StuckNotice): { title: string; text: string }
|
|
|
153
160
|
return {
|
|
154
161
|
title: "定时任务被卡住",
|
|
155
162
|
text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
|
|
156
|
-
+
|
|
163
|
+
+ `距上次任务活动或实际开始执行超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
|
|
157
164
|
};
|
|
158
165
|
}
|
|
159
166
|
return {
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -412,8 +412,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
412
412
|
// received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
|
|
413
413
|
// 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
|
|
414
414
|
// 后者才是真的上一次还没做完。
|
|
415
|
-
const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; consumed_at: string | null; started_at: string | null; status: string }>(
|
|
416
|
-
`SELECT r.task_id, t.created_at, t.consumed_at, t.started_at, t.status,
|
|
415
|
+
const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; consumed_at: string | null; started_at: string | null; last_activity_at: string | null; status: string }>(
|
|
416
|
+
`SELECT r.task_id, t.created_at, t.consumed_at, t.started_at, t.last_activity_at, t.status,
|
|
417
417
|
CASE WHEN t.status IN ('acked', 'running')
|
|
418
418
|
OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
|
|
419
419
|
THEN 1 ELSE 0 END AS received
|
package/src/server.ts
CHANGED
|
@@ -1520,7 +1520,7 @@ return Bun.serve({
|
|
|
1520
1520
|
// #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
|
|
1521
1521
|
// keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
|
|
1522
1522
|
const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
|
|
1523
|
-
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
|
|
1523
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true, revokeNetworkTokens: body.revoke_network_tokens === true });
|
|
1524
1524
|
if (result.ok) {
|
|
1525
1525
|
// 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
|
|
1526
1526
|
const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
3
|
+
|
|
4
|
+
type Context = { task_id: string; thread_id: string; turn_id: string };
|
|
5
|
+
|
|
6
|
+
/** Renew only an already-consumed, exact original turn. No alias/content/heartbeat inference. */
|
|
7
|
+
export function markTaskActivity(input: {
|
|
8
|
+
tokenId: string | null | undefined;
|
|
9
|
+
networkId: string | null | undefined;
|
|
10
|
+
nodeToken: boolean;
|
|
11
|
+
identityRejected: boolean;
|
|
12
|
+
taskIds: string[];
|
|
13
|
+
contexts: Context[];
|
|
14
|
+
}) {
|
|
15
|
+
const deny = (error: string, task_id?: string) => ({ ok: false as const, error, ...(task_id ? { task_id } : {}) });
|
|
16
|
+
if (!input.nodeToken || !input.networkId || input.identityRejected) return deny("node_token_required");
|
|
17
|
+
if (db.transactionalFeaturesRefusal !== null) return deny("task_runtime_evidence_backend_unsupported");
|
|
18
|
+
if (new Set(input.taskIds).size !== input.taskIds.length) return deny("duplicate_task_id");
|
|
19
|
+
const contexts = new Map(input.contexts.map((c) => [c.task_id, c]));
|
|
20
|
+
if (contexts.size !== input.contexts.length) return deny("duplicate_task_context");
|
|
21
|
+
const foreign = input.contexts.find((c) => !input.taskIds.includes(c.task_id));
|
|
22
|
+
if (foreign) return deny("task_context_not_requested", foreign.task_id);
|
|
23
|
+
const missing = input.taskIds.find((id) => !contexts.has(id));
|
|
24
|
+
if (missing) return deny("task_activity_context_required", missing);
|
|
25
|
+
|
|
26
|
+
return db.transaction(() => {
|
|
27
|
+
// Resolve inside the transaction: renamed sessions and same-name rows cannot substitute for the token.
|
|
28
|
+
const caller = resolveNodeCaller(input.tokenId);
|
|
29
|
+
if (!caller.ok || caller.kind !== "node" || caller.networkId !== input.networkId) return deny("node_token_required");
|
|
30
|
+
const placeholders = input.taskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
31
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; status: string; consumed_at: string | null; thread_id: string | null; turn_id: string | null }>(
|
|
32
|
+
`SELECT task_id, to_node_id, status, consumed_at, thread_id, turn_id FROM tasks
|
|
33
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`, input.networkId, ...input.taskIds,
|
|
34
|
+
);
|
|
35
|
+
const byId = new Map(rows.map((row) => [row.task_id, row]));
|
|
36
|
+
for (const id of input.taskIds) {
|
|
37
|
+
const row = byId.get(id);
|
|
38
|
+
if (!row || row.to_node_id !== caller.nodeId) return deny("task_not_owned", id);
|
|
39
|
+
if (!["created", "delivered", "acked", "running"].includes(row.status)) return deny("task_activity_terminal", id);
|
|
40
|
+
if (!row.consumed_at || !row.thread_id || !row.turn_id) return deny("task_activity_not_consumed", id);
|
|
41
|
+
const context = contexts.get(id)!;
|
|
42
|
+
if (row.thread_id !== context.thread_id || row.turn_id !== context.turn_id) return deny("task_runtime_context_conflict", id);
|
|
43
|
+
}
|
|
44
|
+
for (const id of input.taskIds) {
|
|
45
|
+
const context = contexts.get(id)!;
|
|
46
|
+
const changed = db.run(
|
|
47
|
+
`UPDATE tasks SET last_activity_at = datetime('now')
|
|
48
|
+
WHERE task_id = ?1 AND network_id = ?2 AND to_node_id = ?3
|
|
49
|
+
AND thread_id = ?4 AND turn_id = ?5 AND consumed_at IS NOT NULL
|
|
50
|
+
AND status IN ('created', 'delivered', 'acked', 'running')`,
|
|
51
|
+
[id, input.networkId, caller.nodeId, context.thread_id, context.turn_id],
|
|
52
|
+
);
|
|
53
|
+
if (changed.changes !== 1) throw new Error(`task_activity_write_race:${id}`);
|
|
54
|
+
}
|
|
55
|
+
return { ok: true as const, tasks: db.all<{ task_id: string; last_activity_at: string }>(
|
|
56
|
+
`SELECT task_id, last_activity_at FROM tasks WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
57
|
+
input.networkId, ...input.taskIds,
|
|
58
|
+
) };
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { markTaskActivity } from "./task-activity.js";
|
|
2
3
|
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
4
|
import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
|
|
4
5
|
import { requestScopedSecrets, getScopedSecrets, pullScopedSecrets, ackScopedSecrets, requestNodeSecrets, pullNodeSecrets, ackNodeSecrets } from './scoped-secrets.js';
|
|
@@ -1696,9 +1697,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1696
1697
|
|
|
1697
1698
|
server.tool(
|
|
1698
1699
|
"mark_tasks_consumed",
|
|
1699
|
-
"Internal
|
|
1700
|
-
taskRuntimeEvidenceSchema,
|
|
1701
|
-
async ({ task_ids, task_contexts }) =>
|
|
1700
|
+
"Internal token-bound task consumption; activity_only reports original-turn activity.",
|
|
1701
|
+
{ ...taskRuntimeEvidenceSchema, activity_only: z.boolean().optional() },
|
|
1702
|
+
async ({ task_ids, task_contexts, activity_only }) => activity_only
|
|
1703
|
+
? { content: [{ type: "text" as const, text: JSON.stringify(markTaskActivity({ tokenId: callerTokenId, networkId: enforceNetworkId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected, taskIds: task_ids, contexts: task_contexts ?? [] })) }] }
|
|
1704
|
+
: markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1702
1705
|
);
|
|
1703
1706
|
|
|
1704
1707
|
// ═══════════════════════════════════════════
|