@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.100
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +965 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +641 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { redactTokens, redactMessageRow } from "./redact-tokens.js";
|
|
3
|
+
|
|
4
|
+
describe("#1459 ③ redact-at-read", () => {
|
|
5
|
+
test("🔴 各种前缀形状都被遮住,且前缀保留(读者仍知道泄漏的是哪一类)", () => {
|
|
6
|
+
const cases: Array<[string, string]> = [
|
|
7
|
+
["ntok_abcdef123456", "ntok_"],
|
|
8
|
+
["utok_abcdef123456", "utok_"],
|
|
9
|
+
["ghp_" + "A".repeat(24), "ghp_"],
|
|
10
|
+
["github_pat_" + "B".repeat(24), "github_pat_"],
|
|
11
|
+
["xoxb-1234567890-abcdef", "xoxb-"],
|
|
12
|
+
["sk-" + "C".repeat(20), "sk-"],
|
|
13
|
+
];
|
|
14
|
+
for (const [raw, prefix] of cases) {
|
|
15
|
+
const out = redactTokens(`before ${raw} after`);
|
|
16
|
+
expect(out).toContain("***redacted***");
|
|
17
|
+
expect(out).toContain(prefix);
|
|
18
|
+
expect(out).not.toContain(raw); // 原值一定不能整体幸存
|
|
19
|
+
expect(out).toContain("before"); // 周围文本不受影响
|
|
20
|
+
expect(out).toContain("after");
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("🔴 保守:不像凭据的内容一个字都不能改", () => {
|
|
25
|
+
for (const s of [
|
|
26
|
+
"任务完成,磁盘 96%",
|
|
27
|
+
"see https://example.com/a-b_c",
|
|
28
|
+
"sk-", // 太短,不是凭据
|
|
29
|
+
"ntok_", // 光前缀
|
|
30
|
+
"the stock ticker is SKU-12",
|
|
31
|
+
]) {
|
|
32
|
+
expect(redactTokens(s)).toBe(s);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("同一段里的多个 token 全部被遮", () => {
|
|
37
|
+
const out = redactTokens(`a ntok_aaaaaaaaaa b sk-${"z".repeat(20)} c`);
|
|
38
|
+
expect(out.match(/\*\*\*redacted\*\*\*/g)?.length).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("redactMessageRow 只碰自由文本字段,不动结构字段", () => {
|
|
42
|
+
const row = {
|
|
43
|
+
message_id: "dm_ntok_looking_but_is_an_id",
|
|
44
|
+
user_id: "u_1",
|
|
45
|
+
content: "here is ntok_abcdef123456",
|
|
46
|
+
title: "sk-" + "d".repeat(20),
|
|
47
|
+
meta_json: JSON.stringify({ note: "ghp_" + "E".repeat(24) }),
|
|
48
|
+
created_at: "2026-08-30 00:00:00",
|
|
49
|
+
};
|
|
50
|
+
const out = redactMessageRow(row);
|
|
51
|
+
expect(out.content).toContain("***redacted***");
|
|
52
|
+
expect(out.title).toContain("***redacted***");
|
|
53
|
+
expect(out.meta_json).toContain("***redacted***");
|
|
54
|
+
// 结构字段原样 —— 即便 message_id 里恰好有个像前缀的子串
|
|
55
|
+
expect(out.message_id).toBe(row.message_id);
|
|
56
|
+
expect(out.user_id).toBe(row.user_id);
|
|
57
|
+
expect(out.created_at).toBe(row.created_at);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("非字符串原样返回(null/数字不该被改成字符串)", () => {
|
|
61
|
+
expect(redactTokens(null as any)).toBeNull();
|
|
62
|
+
expect(redactTokens(42 as any)).toBe(42);
|
|
63
|
+
expect(redactMessageRow({ content: null, title: 7 } as any)).toEqual({ content: null, title: 7 } as any);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// #1459 ③ —— 读取时的 token 形状脱敏(redact-at-read)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么在**读取**侧而不是写入侧:写入方是 agent,不受信任。存储侧已经走
|
|
4
|
+
// normalizeMetaJson(跨主机路径脱敏,与 inbox 一致),那一层防的是"路径泄漏";
|
|
5
|
+
// 这一层防的是"有人把凭据塞进消息正文或 meta"。两层管的不是同一件事。
|
|
6
|
+
//
|
|
7
|
+
// 🔴 保守优先:宁可漏脱敏一个不像凭据的串,也不要把正常内容改花。所以只匹配
|
|
8
|
+
// 几个**有明确前缀**的形状,不做熵检测、不猜。
|
|
9
|
+
|
|
10
|
+
/** 有明确前缀的凭据形状。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 前缀用**捕获组显式声明**,不靠"切到第一个下划线"之类的推断 ——
|
|
13
|
+
* 第一版就是那么写的,`github_pat_xxx` 被切成了 `github_`,把"这是哪一类
|
|
14
|
+
* 凭据"这个唯一有用的信息也遮掉了。声明比推断短,也不会错。 */
|
|
15
|
+
const TOKEN_PATTERNS: ReadonlyArray<RegExp> = [
|
|
16
|
+
/\b(ntok_|utok_|atok_)[A-Za-z0-9_-]{6,}/g, // 本仓自己的 token
|
|
17
|
+
/\b(github_pat_)[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT(必须排在 ghp_ 之前无关,但更长的前缀先声明更清楚)
|
|
18
|
+
/\b(ghp_)[A-Za-z0-9]{20,}/g, // GitHub classic PAT
|
|
19
|
+
/\b(xox[bpoars]-)[A-Za-z0-9-]{10,}/g, // Slack
|
|
20
|
+
/\b(sk-)[A-Za-z0-9_-]{16,}/g, // OpenAI 风格
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 对任意文本做 token 形状脱敏。非字符串原样返回。 */
|
|
24
|
+
export function redactTokens<T>(value: T): T {
|
|
25
|
+
if (typeof value !== "string") return value;
|
|
26
|
+
let out: string = value;
|
|
27
|
+
// 保留捕获到的前缀(让读者知道泄漏的是哪一类),其余一律遮掉;不保留尾部 ——
|
|
28
|
+
// 尾部对识别没帮助,却能帮攻击者缩小搜索空间。
|
|
29
|
+
for (const re of TOKEN_PATTERNS) out = out.replace(re, (_m, prefix: string) => `${prefix}***redacted***`);
|
|
30
|
+
return out as unknown as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 对一行消息做脱敏:只碰会承载自由文本的字段,不动 id/时间戳等结构字段。 */
|
|
34
|
+
export function redactMessageRow<T extends Record<string, unknown>>(row: T): T {
|
|
35
|
+
const out: Record<string, unknown> = { ...row };
|
|
36
|
+
for (const field of ["content", "title", "meta_json"]) {
|
|
37
|
+
if (typeof out[field] === "string") out[field] = redactTokens(out[field] as string);
|
|
38
|
+
}
|
|
39
|
+
return out as T;
|
|
40
|
+
}
|
package/src/rename.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
|
+
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
18
19
|
import { pushEvent } from "./push";
|
|
19
20
|
import { eventBus } from "./event_bus";
|
|
20
21
|
|
|
@@ -35,7 +36,8 @@ export interface CanonicalAlias {
|
|
|
35
36
|
|
|
36
37
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
37
38
|
const role = getUserNetworkRole(userId, networkId);
|
|
38
|
-
|
|
39
|
+
// 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
|
|
40
|
+
return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// Resolve committed alias renames (old -> new), following short chains such
|
|
@@ -214,8 +216,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
214
216
|
// without this it would never receive node.renamed (#84 SSE channel fix:
|
|
215
217
|
// N站马 confirmed the dashboard listens on the user channel). All members get
|
|
216
218
|
// it, not just the owner, since any member's dashboard should reflect the rename.
|
|
219
|
+
// 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
|
|
217
220
|
for (const member of getNetworkMembers(txn.network_id)) {
|
|
218
|
-
if (member.username)
|
|
221
|
+
if (!member.username) continue;
|
|
222
|
+
if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
|
|
223
|
+
pushEvent(member.username, renamedEvent, txn.network_id);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
return { ok: true, txn_id: txnId };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// f015d9d6 根因隔离:admin(dashboard/人类) 发给「无 node_id 的 MCP 会话」的任务,
|
|
2
|
+
// 其回执被 reply_target_mismatch 拒绝。
|
|
3
|
+
//
|
|
4
|
+
// 上报时给出的假设是「校验依赖投递绑定 ⇒ 任务还没真正 deliver 进会话时回执会被拒」。
|
|
5
|
+
// 本文件把两个变量分开量,一次只动一个:
|
|
6
|
+
// 变量 1 = 任务是否已投递(status created vs delivered,有无 SSE 订阅者)
|
|
7
|
+
// 变量 2 = 调用方是否显式传 alias
|
|
8
|
+
// 如果假设成立,变量 1 应当决定结果;如果 alias 推导才是决定因素,变量 2 才会。
|
|
9
|
+
import { beforeEach, afterAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
import { registerTools } from "./tools.js";
|
|
13
|
+
import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_admin_reply_f015";
|
|
16
|
+
const USER = "user_admin_reply_f015";
|
|
17
|
+
const ADMIN = "admin-f015"; // 人类/dashboard 发起方,无 node
|
|
18
|
+
const AGENT = "agent-mcp-f015"; // MCP 会话:sessions 行 node_id = NULL
|
|
19
|
+
const AGENT_NODE = "node-agent-f015"; // token 绑定的 node(会话本身仍无 node_id)
|
|
20
|
+
|
|
21
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
22
|
+
|
|
23
|
+
function cleanup() {
|
|
24
|
+
__resetSSEClientsForTest();
|
|
25
|
+
for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
|
|
26
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
27
|
+
}
|
|
28
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
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]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seed() {
|
|
34
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
|
|
35
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
36
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
37
|
+
db.run(
|
|
38
|
+
"INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', '{}', datetime('now'), datetime('now'), 'active')",
|
|
39
|
+
[AGENT_NODE, AGENT, NET],
|
|
40
|
+
);
|
|
41
|
+
// 🔴 关键夹具属性:会话行 node_id 为 NULL —— 这正是 MCP 挂载的 claude-code 会话的形状
|
|
42
|
+
db.run(
|
|
43
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now'), datetime('now'))",
|
|
44
|
+
[`cc-n_${AGENT}`, AGENT, NET],
|
|
45
|
+
);
|
|
46
|
+
db.run(
|
|
47
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
|
|
48
|
+
[`token-${AGENT}`, `hash-${AGENT}`, USER, NET, `node:${AGENT}`, AGENT_NODE],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toolsFor(alias: string): Record<string, ToolHandler> {
|
|
53
|
+
const server = new McpServer({ name: "admin-reply-f015", version: "0" }) as any;
|
|
54
|
+
const tools: Record<string, ToolHandler> = {};
|
|
55
|
+
const original = server.tool.bind(server);
|
|
56
|
+
server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
|
|
57
|
+
tools[name] = handler; return original(name, description, schema, handler);
|
|
58
|
+
};
|
|
59
|
+
registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
|
|
60
|
+
return tools;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parse(res: { content: Array<{ text: string }> }) { return JSON.parse(res.content[0].text); }
|
|
64
|
+
|
|
65
|
+
/** 造一条 admin(人类) → AGENT 的任务;delivered 控制「是否已投递」这一个变量。 */
|
|
66
|
+
function seedAdminTask(taskId: string, delivered: boolean) {
|
|
67
|
+
db.run(
|
|
68
|
+
`INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, content, status, priority, network_id, created_at)
|
|
69
|
+
VALUES (?1, ?2, NULL, ?3, NULL, 'ping', ?4, 'normal', ?5, datetime('now'))`,
|
|
70
|
+
[taskId, ADMIN, AGENT, delivered ? "delivered" : "created", NET],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("f015d9d6 — admin→无 node_id 的 MCP 会话,回执绑定", () => {
|
|
75
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
76
|
+
afterAll(() => cleanup());
|
|
77
|
+
|
|
78
|
+
test("变量1:任务【未投递】+ 省略 alias(走 #1085 推导)⇒ 回执应当成功", async () => {
|
|
79
|
+
seedAdminTask("t-undelivered", false);
|
|
80
|
+
const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-undelivered", text: "done", status: "completed" }));
|
|
81
|
+
expect(r).toEqual(expect.objectContaining({ ok: true }));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("变量1 对照:任务【已投递且有 SSE 订阅者】+ 省略 alias ⇒ 同样成功", async () => {
|
|
85
|
+
seedAdminTask("t-delivered", true);
|
|
86
|
+
createSSEStream(AGENT, NET);
|
|
87
|
+
const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-delivered", text: "done", status: "completed" }));
|
|
88
|
+
expect(r).toEqual(expect.objectContaining({ ok: true }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("变量2:显式传一个不等于任务发起方的 alias ⇒ reply_target_mismatch", async () => {
|
|
92
|
+
seedAdminTask("t-wrong-alias", true);
|
|
93
|
+
const r = parse(await toolsFor(AGENT).send_reply({ alias: "someone-else", in_reply_to: "t-wrong-alias", text: "done", status: "completed" }));
|
|
94
|
+
expect(r).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// report_status 的 host 遥测 schema 必须接受 agent-node **声明会发**的 null。
|
|
2
|
+
//
|
|
3
|
+
// 来源不是推理,是 #1225 复现容器里量到的一次真崩:`--network none` 里
|
|
4
|
+
// agent-node 一启动就死在
|
|
5
|
+
// MCP error -32602: Invalid input: expected string, received null at host.ip
|
|
6
|
+
// 而 register() 是 `await callCommHub("report_status", …)` 且没有 catch ——
|
|
7
|
+
// 于是**整个节点进程当场退出**,和 runtime 无关(opencode/claude/codex 一样)。
|
|
8
|
+
//
|
|
9
|
+
// 触发条件不是"容器",是"这台机器没有非回环 IPv4":
|
|
10
|
+
// agent-node 的 firstNonInternalIPv4()(host-telemetry.ts:48-59)在那种机器上
|
|
11
|
+
// 返回 null,而 HostTelemetry 的类型本来就写着 `ip: string | null`。
|
|
12
|
+
//
|
|
13
|
+
// 🔴 这里断言的是 **schema 本身**,不是 handler。直接调 handler 会绕过 zod ——
|
|
14
|
+
// 而缺陷恰恰只存在于 zod 那一层,绕过去就永远绿。
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { registerTools } from "./tools.js";
|
|
19
|
+
|
|
20
|
+
function captureToolSchemas(): Record<string, any> {
|
|
21
|
+
const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
|
|
22
|
+
const schemas: Record<string, any> = {};
|
|
23
|
+
const origTool = mcp.tool.bind(mcp);
|
|
24
|
+
mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
|
|
25
|
+
schemas[name] = schema;
|
|
26
|
+
return origTool(name, desc, schema, handler);
|
|
27
|
+
};
|
|
28
|
+
registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
|
|
29
|
+
return schemas;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const schemas = captureToolSchemas();
|
|
33
|
+
|
|
34
|
+
describe("report_status host telemetry schema", () => {
|
|
35
|
+
test("前提:抓到的确实是 report_status 的 host 形状", () => {
|
|
36
|
+
expect(schemas.report_status).toBeTruthy();
|
|
37
|
+
expect(schemas.report_status.host).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("🔴 host.ip = null 必须被接受(没有非回环 IPv4 的机器就是发 null)", () => {
|
|
41
|
+
const host = schemas.report_status.host;
|
|
42
|
+
const parsed = host.parse({ hostname: "boxy", ip: null });
|
|
43
|
+
expect(parsed.ip).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("🔴 host.hostname = null 同样被接受(与 ip 同为字符串字段,一起对齐)", () => {
|
|
47
|
+
const host = schemas.report_status.host;
|
|
48
|
+
expect(host.parse({ hostname: null, ip: "10.0.0.2" }).hostname).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("agent-node 那条完整心跳负载整体过 schema", () => {
|
|
52
|
+
// 形状抄自 agent-node/src/host-telemetry.ts 的 HostTelemetry:
|
|
53
|
+
// 没有网络、没有 /proc 时每一项都可能是 null。
|
|
54
|
+
const full = {
|
|
55
|
+
hostname: "unknown", ip: null,
|
|
56
|
+
cpu_load_1min: null, cpu_cores: null,
|
|
57
|
+
mem_total_gb: null, mem_used_gb: null, mem_avail_gb: null,
|
|
58
|
+
disk_total_gb: null, disk_used_gb: null, disk_avail_gb: null,
|
|
59
|
+
};
|
|
60
|
+
expect(() => schemas.report_status.host.parse(full)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("放宽的只有 null —— 类型错的值仍然被拒", () => {
|
|
64
|
+
const host = schemas.report_status.host;
|
|
65
|
+
expect(() => host.parse({ ip: 12345 })).toThrow();
|
|
66
|
+
expect(() => host.parse({ ip: "x".repeat(201) })).toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("整条 report_status 负载(不只是 host 子对象)也接受 ip=null", () => {
|
|
70
|
+
const full = z.object(schemas.report_status);
|
|
71
|
+
expect(() => full.parse({
|
|
72
|
+
resume_id: "probe-resume", alias: "probe-node", status: "idle",
|
|
73
|
+
host: { hostname: "unknown", ip: null },
|
|
74
|
+
})).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
|
|
79
|
+
*
|
|
80
|
+
* 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
|
|
81
|
+
* 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
|
|
82
|
+
* 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
|
|
83
|
+
* 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
|
|
84
|
+
*
|
|
85
|
+
* 消毒在读取处(`/api/host-supervisors`),不在这里。 */
|
|
86
|
+
describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
|
|
87
|
+
const caps = () => schemas.report_status.config_snapshot;
|
|
88
|
+
|
|
89
|
+
test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
|
|
90
|
+
expect(caps()).toBeTruthy();
|
|
91
|
+
const parsed = caps().parse({
|
|
92
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
93
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
94
|
+
});
|
|
95
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("正常值:透传", () => {
|
|
99
|
+
const parsed = caps().parse({
|
|
100
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
101
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
|
|
102
|
+
});
|
|
103
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
|
|
107
|
+
const parsed = caps().parse({
|
|
108
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
109
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
|
|
110
|
+
});
|
|
111
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
|
|
115
|
+
* 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
|
|
116
|
+
test.each([
|
|
117
|
+
["负数", -1],
|
|
118
|
+
["非整数", 1234.5678],
|
|
119
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
120
|
+
["NaN", Number.NaN],
|
|
121
|
+
])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
|
|
122
|
+
expect(() => caps().parse({
|
|
123
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
124
|
+
daemon_capabilities: { can_create_nodes: false,
|
|
125
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
126
|
+
create_capability_observed_ms_ago: value },
|
|
127
|
+
})).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
|
|
131
|
+
* 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
|
|
132
|
+
* null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
|
|
133
|
+
test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
|
|
134
|
+
const parsed = caps().parse({
|
|
135
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
136
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
|
|
137
|
+
});
|
|
138
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
139
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
|
|
144
|
+
*
|
|
145
|
+
* 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
|
|
146
|
+
* hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
|
|
147
|
+
* strict 拒掉整份 report(节点当场失联,#1225 的形状)。
|
|
148
|
+
*
|
|
149
|
+
* 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
|
|
150
|
+
* 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
|
|
151
|
+
* 而不是「必须是某个值」。 */
|
|
152
|
+
describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
|
|
153
|
+
const rs = () => schemas.report_status;
|
|
154
|
+
const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
|
|
155
|
+
|
|
156
|
+
function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = schema.parse({ ...base, ...UNKNOWN });
|
|
159
|
+
return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
|
|
160
|
+
} catch { return "rejected"; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const SIDE_THREAD = {
|
|
164
|
+
supported: true, runtime: "codex", runtimeVersion: "1.0",
|
|
165
|
+
topology: "native-exact-fork", evidenceRevision: "r1",
|
|
166
|
+
};
|
|
167
|
+
const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
|
|
168
|
+
|
|
169
|
+
test.each([
|
|
170
|
+
["host", () => rs().host, {}, "dropped"],
|
|
171
|
+
["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
|
|
172
|
+
["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
|
|
173
|
+
["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
|
|
174
|
+
])("%s → 未知键被 %s", (_name, get, base, expected) => {
|
|
175
|
+
expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
|
|
179
|
+
const parsed: any = rs().config_snapshot.parse({
|
|
180
|
+
...SNAP_BASE,
|
|
181
|
+
daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
|
|
182
|
+
});
|
|
183
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
184
|
+
expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
|
|
188
|
+
expect(() => rs().config_snapshot.parse({
|
|
189
|
+
...SNAP_BASE,
|
|
190
|
+
side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
|
|
191
|
+
})).toThrow();
|
|
192
|
+
// 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
|
|
193
|
+
// 和 strict 无关。
|
|
194
|
+
expect(() => rs().config_snapshot.parse({
|
|
195
|
+
...SNAP_BASE, side_thread_capability: SIDE_THREAD,
|
|
196
|
+
})).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
|
|
200
|
+
* 那段注释描述的「不对称」就不存在了,而这条会先红。 */
|
|
201
|
+
test("这张表确实是不对称的(两种后果各至少一个)", () => {
|
|
202
|
+
const results = [
|
|
203
|
+
acceptsUnknownKey(rs().host, {}),
|
|
204
|
+
acceptsUnknownKey(rs().process_telemetry, {}),
|
|
205
|
+
acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
|
|
206
|
+
acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
|
|
207
|
+
];
|
|
208
|
+
expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
|
|
209
|
+
expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// 同一 alias 换 resume_id 上报时,hub 的 report_status 走的是「DELETE 旧行 + INSERT 新行」,
|
|
2
|
+
// ON CONFLICT 里的 COALESCE 一次都不会触发 —— 上报里没带的描述性列(version / agent /
|
|
3
|
+
// hostname / 遥测 / registered_at)全部变 NULL。
|
|
4
|
+
//
|
|
5
|
+
// 2026-09-04 DEV 真机(hub 日志):
|
|
6
|
+
// 12:46:47 grok-v1 (sdk-n_6c) → report_status: working version=2.5.0-preview.64
|
|
7
|
+
// 12:46:53 grok-v1 (grok-cli) → report_status: working ← TUI 的 MCP 上报,不带 version
|
|
8
|
+
// 12:46:54 grok-v1 (sdk-n_6c) → report_status: idle ← 又换回来,也不带 version
|
|
9
|
+
// 结果:version=NULL、registered_at 被改成 12:46:54。舰队里 grok 3/10、claude 5/15 有 version,
|
|
10
|
+
// codex 34/34 有 —— 差别就是有没有第二个上报者。
|
|
11
|
+
//
|
|
12
|
+
// 判据:换 resume_id 之后,只有一行;上报了的列以上报为准,没上报的列从被替换的那行接手。
|
|
13
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { registerTools } from "./tools.js";
|
|
17
|
+
|
|
18
|
+
const NET_ID = "net_handover";
|
|
19
|
+
const USER_ID = "u_handover";
|
|
20
|
+
const NODE_ID = "n_handover";
|
|
21
|
+
const ALIAS = "grok-handover";
|
|
22
|
+
const TOK_ID = "tok_handover";
|
|
23
|
+
const RESUME_SDK = `sdk-${NODE_ID}`;
|
|
24
|
+
const RESUME_CLI = `grok-cli-${NODE_ID}`;
|
|
25
|
+
const OLD_REGISTERED_AT = "2026-01-02 03:04:05";
|
|
26
|
+
|
|
27
|
+
type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
for (const [sql, params] of [
|
|
31
|
+
["DELETE FROM agent_telemetry WHERE network_id = ?1", [NET_ID]],
|
|
32
|
+
["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
|
|
33
|
+
["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
|
|
34
|
+
["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
|
|
35
|
+
["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
|
|
36
|
+
["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
|
|
37
|
+
["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
|
|
38
|
+
] as const) {
|
|
39
|
+
try { db.run(sql, params as any); } catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
beforeEach(cleanup);
|
|
43
|
+
afterAll(cleanup);
|
|
44
|
+
|
|
45
|
+
function seed() {
|
|
46
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
|
|
47
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
|
|
48
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
51
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
|
|
52
|
+
[NODE_ID, ALIAS, ALIAS, NET_ID, "dev-box"],
|
|
53
|
+
);
|
|
54
|
+
// agent-node 自己注册的那行:带 version / agent / hostname / 遥测 / 早就存在的 registered_at
|
|
55
|
+
db.run(
|
|
56
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, version, agent, hostname, server, project_dir,
|
|
57
|
+
cpu_cores, mem_total_gb, disk_total_gb, registered_at, updated_at, last_seen_at)
|
|
58
|
+
VALUES (?1, ?2, 'working', ?3, ?4, '2.5.0-preview.64', 'agent-node:grok-build-cli', 'dev-box', 'dev-box', '/home/x/proj',
|
|
59
|
+
8, 61.4, 500, ?5, datetime('now'), datetime('now'))`,
|
|
60
|
+
[RESUME_SDK, ALIAS, NODE_ID, NET_ID, OLD_REGISTERED_AT],
|
|
61
|
+
);
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
64
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
65
|
+
[TOK_ID, USER_ID, NET_ID, `node:${ALIAS}`, `hash_${TOK_ID}`],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildHandlers(): Record<string, ToolHandler> {
|
|
70
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
71
|
+
const tools: Record<string, ToolHandler> = {};
|
|
72
|
+
const origTool = server.tool.bind(server);
|
|
73
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
|
|
74
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
75
|
+
if (origRegisterTool) {
|
|
76
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
|
|
77
|
+
}
|
|
78
|
+
registerTools(server, undefined, NET_ID, USER_ID, ALIAS, true, TOK_ID);
|
|
79
|
+
return tools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function call(handler: ToolHandler, args: any): Promise<any> {
|
|
83
|
+
const r = await handler(args);
|
|
84
|
+
return JSON.parse(r.content[0]!.text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Row = { resume_id: string; status: string; version: string | null; agent: string | null; hostname: string | null;
|
|
88
|
+
cpu_cores: number | null; mem_total_gb: number | null; registered_at: string; task: string | null };
|
|
89
|
+
function rows(): Row[] {
|
|
90
|
+
return db.all("SELECT resume_id, status, version, agent, hostname, cpu_cores, mem_total_gb, registered_at, task FROM sessions WHERE alias = ?1 AND network_id = ?2", [ALIAS, NET_ID]) as Row[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("report_status: 同 alias 换 resume_id 时,描述性列从被替换的那行接手", () => {
|
|
94
|
+
test("第二个上报者(grok-cli)不带 version/agent/遥测 → 这些列保留,status/task 以本次为准,仍只有一行", async () => {
|
|
95
|
+
seed();
|
|
96
|
+
const tools = buildHandlers();
|
|
97
|
+
const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", task: "PING 已收到", network_id: NET_ID });
|
|
98
|
+
expect(r.ok, JSON.stringify(r)).toBe(true);
|
|
99
|
+
const all = rows();
|
|
100
|
+
expect(all.length).toBe(1);
|
|
101
|
+
const row = all[0]!;
|
|
102
|
+
expect(row.resume_id).toBe(RESUME_CLI);
|
|
103
|
+
expect(row.status).toBe("working");
|
|
104
|
+
expect(row.task).toBe("PING 已收到");
|
|
105
|
+
expect(row.version).toBe("2.5.0-preview.64");
|
|
106
|
+
expect(row.agent).toBe("agent-node:grok-build-cli");
|
|
107
|
+
expect(row.hostname).toBe("dev-box");
|
|
108
|
+
expect(row.cpu_cores).toBe(8);
|
|
109
|
+
expect(row.mem_total_gb).toBe(61.4);
|
|
110
|
+
expect(row.registered_at).toBe(OLD_REGISTERED_AT);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("上报里带了的列以上报为准(version 变了就用新的)", async () => {
|
|
114
|
+
seed();
|
|
115
|
+
const tools = buildHandlers();
|
|
116
|
+
const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "idle", version: "9.9.9", hostname: "other-box", network_id: NET_ID });
|
|
117
|
+
expect(r.ok, JSON.stringify(r)).toBe(true);
|
|
118
|
+
const row = rows()[0]!;
|
|
119
|
+
expect(rows().length).toBe(1);
|
|
120
|
+
expect(row.version).toBe("9.9.9");
|
|
121
|
+
expect(row.hostname).toBe("other-box");
|
|
122
|
+
expect(row.agent).toBe("agent-node:grok-build-cli");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("再换回原 resume_id(sdk)也不丢:两个上报者轮流说话,version 始终在", async () => {
|
|
126
|
+
seed();
|
|
127
|
+
const tools = buildHandlers();
|
|
128
|
+
await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", network_id: NET_ID });
|
|
129
|
+
const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
|
|
130
|
+
expect(r.ok, JSON.stringify(r)).toBe(true);
|
|
131
|
+
const all = rows();
|
|
132
|
+
expect(all.length).toBe(1);
|
|
133
|
+
expect(all[0]!.resume_id).toBe(RESUME_SDK);
|
|
134
|
+
expect(all[0]!.status).toBe("idle");
|
|
135
|
+
expect(all[0]!.version).toBe("2.5.0-preview.64");
|
|
136
|
+
expect(all[0]!.registered_at).toBe(OLD_REGISTERED_AT);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("没有别的行可接手时(全新 alias)行为不变:INSERT 一行,registered_at 取 now", async () => {
|
|
140
|
+
seed();
|
|
141
|
+
db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]);
|
|
142
|
+
const tools = buildHandlers();
|
|
143
|
+
const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
|
|
144
|
+
expect(r.ok, JSON.stringify(r)).toBe(true);
|
|
145
|
+
const all = rows();
|
|
146
|
+
expect(all.length).toBe(1);
|
|
147
|
+
expect(all[0]!.version).toBeNull();
|
|
148
|
+
expect(all[0]!.registered_at).not.toBe(OLD_REGISTERED_AT);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// #434: real-server integration tests must know their DB before importing
|
|
2
|
+
// auth/db/server modules. Setting COMMHUB_DB in beforeAll is too late because
|
|
3
|
+
// ESM dependencies have already evaluated the db singleton.
|
|
4
|
+
if (!process.env.COMMHUB_DB) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"explicit_test_database_required: run `cd server && bun run test`, " +
|
|
7
|
+
"or set COMMHUB_DB to an isolated temporary path before running this file",
|
|
8
|
+
);
|
|
9
|
+
}
|