@sleep2agi/commhub-server 0.9.0-preview.5 → 0.9.0-preview.50

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.
Files changed (128) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +80 -1
  24. package/src/create-node-validate.ts +49 -5
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +690 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/hub-timestamp.test.ts +38 -0
  47. package/src/hub-timestamp.ts +27 -0
  48. package/src/inbox-count-sse.test.ts +310 -0
  49. package/src/inbox-count.ts +29 -0
  50. package/src/index.ts +15 -2227
  51. package/src/lifecycle-guard.ts +50 -0
  52. package/src/list-host-supervisors.test.ts +314 -0
  53. package/src/mcp-write-network-resolution.test.ts +429 -0
  54. package/src/network-name-http.test.ts +75 -0
  55. package/src/network-scope.ts +101 -0
  56. package/src/node-attrs-validate.ts +88 -0
  57. package/src/node-attrs.test.ts +217 -0
  58. package/src/node-id-alias.test.ts +188 -0
  59. package/src/node-lifecycle-controllable.test.ts +64 -0
  60. package/src/node-lifecycle-controllable.ts +51 -0
  61. package/src/observer-avatar-http.test.ts +428 -0
  62. package/src/observer-push.test.ts +252 -0
  63. package/src/package-version-consistency.test.ts +25 -0
  64. package/src/peer-reply-atomic.test.ts +455 -0
  65. package/src/peer-reply-capability.test.ts +70 -0
  66. package/src/push.ts +163 -7
  67. package/src/redact-tokens.test.ts +65 -0
  68. package/src/redact-tokens.ts +40 -0
  69. package/src/reply-target-derivation.test.ts +96 -0
  70. package/src/report-status-host-schema.test.ts +211 -0
  71. package/src/report-status-resume-id-handover.test.ts +150 -0
  72. package/src/require-explicit-test-db.ts +9 -0
  73. package/src/resolve-port.test.ts +57 -0
  74. package/src/resolve-port.ts +44 -0
  75. package/src/response-charset.test.ts +130 -0
  76. package/src/rest-explicit-columns-http.test.ts +216 -0
  77. package/src/rest-identity.test.ts +129 -0
  78. package/src/rest-identity.ts +100 -0
  79. package/src/rest-projections.ts +67 -0
  80. package/src/rest-write-network-resolution.test.ts +133 -0
  81. package/src/rules-file-transport.test.ts +235 -0
  82. package/src/runtime-label.test.ts +22 -0
  83. package/src/runtime-label.ts +21 -0
  84. package/src/scheduled-run-terminal.test.ts +268 -0
  85. package/src/scheduled-tasks-http.test.ts +483 -0
  86. package/src/scheduled-tasks.ts +549 -0
  87. package/src/send-reply-agent-warning.test.ts +202 -0
  88. package/src/send-reply-alias-optional.test.ts +131 -0
  89. package/src/send-reply-attachments.test.ts +458 -0
  90. package/src/serve-error.test.ts +38 -0
  91. package/src/serve-error.ts +27 -0
  92. package/src/server.ts +3736 -0
  93. package/src/shared/external-schedule-contract.ts +60 -0
  94. package/src/side-thread-command-transport.test.ts +302 -0
  95. package/src/side-thread-command-transport.ts +318 -0
  96. package/src/side-thread-http-integration.test.ts +233 -0
  97. package/src/side-thread-http.ts +504 -0
  98. package/src/side-thread-production.test.ts +72 -0
  99. package/src/side-thread-production.ts +118 -0
  100. package/src/side-thread.test.ts +1067 -0
  101. package/src/side-thread.ts +2495 -0
  102. package/src/skillhub-http.test.ts +92 -0
  103. package/src/start-node.test.ts +109 -0
  104. package/src/stop-delete-node.test.ts +952 -0
  105. package/src/task-auth-origin-http.test.ts +91 -0
  106. package/src/task-auth-origin.test.ts +24 -0
  107. package/src/task-auth-origin.ts +7 -0
  108. package/src/task-consumption.test.ts +471 -0
  109. package/src/task-diagnostic-http.test.ts +149 -0
  110. package/src/task-diagnostic.test.ts +115 -0
  111. package/src/task-diagnostic.ts +118 -0
  112. package/src/task-idempotency-mcp.test.ts +142 -0
  113. package/src/task-idempotency.test.ts +60 -0
  114. package/src/task-idempotency.ts +75 -0
  115. package/src/task-lifecycle-watcher.test.ts +204 -0
  116. package/src/task-lifecycle-watcher.ts +49 -0
  117. package/src/task-network-resolution-http.test.ts +141 -0
  118. package/src/tasks-pagination-http.test.ts +141 -0
  119. package/src/tools-list-json-schema.test.ts +66 -0
  120. package/src/tools.ts +3026 -197
  121. package/src/update-provider.test.ts +65 -0
  122. package/src/upload-early-reject-guard.test.ts +155 -0
  123. package/src/uploads-http.test.ts +97 -15
  124. package/src/uploads.test.ts +66 -0
  125. package/src/uploads.ts +130 -5
  126. package/src/user-inbox-netid-notnull.test.ts +122 -0
  127. package/src/user-inbox-read-http.test.ts +223 -0
  128. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,129 @@
1
+ // REST from_session identity binding.
2
+ //
3
+ // Run: COMMHUB_DB=/tmp/rest-identity-test.db bun test src/rest-identity.test.ts
4
+ // (COMMHUB_DB must be set explicitly — unset defaults to the production DB.)
5
+ //
6
+ // Assertion discipline: results are compared with toEqual against the whole
7
+ // object, so a widened accepted-set fails instead of silently passing.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { nodeAliasForToken, resolveRestFromSession } from "./rest-identity.js";
11
+
12
+ const NTOK = "ntok_abc123";
13
+ const UTOK = "utok_abc123";
14
+
15
+ describe("REST from_session identity binding", () => {
16
+ test("node token claiming another node's alias is refused", () => {
17
+ expect(resolveRestFromSession({
18
+ token: NTOK,
19
+ tokenName: "node:通信IM牛",
20
+ requestedFrom: "通信龙",
21
+ })).toEqual({
22
+ ok: false,
23
+ error: "from_session_identity_mismatch",
24
+ message: "network token from_session does not match token-bound node alias",
25
+ tokenAlias: "通信IM牛",
26
+ requestedFromSession: "通信龙",
27
+ });
28
+ });
29
+
30
+ test("node token omitting from is bound to its own alias, not 'api'", () => {
31
+ expect(resolveRestFromSession({
32
+ token: NTOK,
33
+ tokenName: "node:通信IM牛",
34
+ requestedFrom: undefined,
35
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
36
+ });
37
+
38
+ test("node token claiming its own alias is accepted", () => {
39
+ expect(resolveRestFromSession({
40
+ token: NTOK,
41
+ tokenName: "node:通信IM牛",
42
+ requestedFrom: "通信IM牛",
43
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
44
+ });
45
+
46
+ test("whitespace padding does not defeat the comparison", () => {
47
+ expect(resolveRestFromSession({
48
+ token: NTOK,
49
+ tokenName: "node:通信IM牛",
50
+ requestedFrom: " 通信IM牛 ",
51
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
52
+ });
53
+
54
+ // The Dashboard posts as its logged-in user; this path must not change.
55
+ test("user token keeps the previous behaviour", () => {
56
+ expect(resolveRestFromSession({
57
+ token: UTOK,
58
+ tokenName: "dashboard",
59
+ requestedFrom: "admin",
60
+ })).toEqual({ ok: true, fromSession: "admin" });
61
+ });
62
+
63
+ test("user token with no from defaults to the authenticated username", () => {
64
+ expect(resolveRestFromSession({
65
+ token: UTOK,
66
+ tokenName: null,
67
+ authenticatedUsername: "admin",
68
+ requestedFrom: undefined,
69
+ })).toEqual({ ok: true, fromSession: "admin" });
70
+ });
71
+
72
+ test("legacy user auth without a username still falls back to 'api'", () => {
73
+ expect(resolveRestFromSession({
74
+ token: UTOK,
75
+ tokenName: null,
76
+ requestedFrom: undefined,
77
+ })).toEqual({ ok: true, fromSession: "api" });
78
+ });
79
+
80
+ // A non-string `from` must not be coerced into an identity.
81
+ test("non-string from is not treated as a claim", () => {
82
+ expect(resolveRestFromSession({
83
+ token: NTOK,
84
+ tokenName: "node:通信IM牛",
85
+ requestedFrom: { toString: () => "通信龙" },
86
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
87
+ });
88
+
89
+ // A network-bound token we cannot resolve to an alias must not be able to
90
+ // call itself anything. Found by independent review of this change.
91
+ describe("ntok_ with an unresolvable alias fails closed", () => {
92
+ for (const tokenName of ["node:", "node: ", "dashboard", null, undefined]) {
93
+ test(`claim is refused when tokenName is ${JSON.stringify(tokenName)}`, () => {
94
+ expect(resolveRestFromSession({
95
+ token: NTOK,
96
+ tokenName,
97
+ requestedFrom: "通信龙",
98
+ })).toEqual({
99
+ ok: false,
100
+ error: "from_session_identity_mismatch",
101
+ message: "network token has no resolvable node alias and may not claim a from_session",
102
+ tokenAlias: "",
103
+ requestedFromSession: "通信龙",
104
+ });
105
+ });
106
+ }
107
+
108
+ test("no claim at all still resolves to 'api'", () => {
109
+ expect(resolveRestFromSession({
110
+ token: NTOK,
111
+ tokenName: "node:",
112
+ requestedFrom: undefined,
113
+ })).toEqual({ ok: true, fromSession: "api" });
114
+ });
115
+ });
116
+
117
+ describe("nodeAliasForToken", () => {
118
+ test("only ntok_ + node: prefix yields an alias", () => {
119
+ expect(nodeAliasForToken(NTOK, "node:X")).toBe("X");
120
+ // A user token whose name merely looks node-shaped must not bind.
121
+ expect(nodeAliasForToken(UTOK, "node:X")).toBeNull();
122
+ // A node token whose name is not node-scoped must not bind.
123
+ expect(nodeAliasForToken(NTOK, "dashboard")).toBeNull();
124
+ expect(nodeAliasForToken(NTOK, null)).toBeNull();
125
+ // `node:` with nothing after it is not an identity.
126
+ expect(nodeAliasForToken(NTOK, "node: ")).toBeNull();
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,100 @@
1
+ // REST from_session identity binding.
2
+ //
3
+ // Both transports resolve the same `api_tokens` row through resolveToken, and
4
+ // auth.ts already states the invariant:
5
+ //
6
+ // "Network-bound node tokens are an identity boundary: they must not spoof
7
+ // another node via from_session."
8
+ //
9
+ // tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
10
+ // POST /api/task did not — it took `body.from` verbatim, so any node holding
11
+ // its own ntok_ could claim another node's alias. That value is stored on the
12
+ // task row and, once sender labels reach the copresence TUIs, rendered to a
13
+ // human as an attribution.
14
+ //
15
+ // This is the same decision expressed as a pure function so every credential
16
+ // kind is a directly constructible test input rather than a hand-assembled
17
+ // Request (the #503 convention in this codebase).
18
+
19
+ export interface RestIdentityInput {
20
+ /** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
21
+ token: string;
22
+ /** `api_tokens.name`; for node tokens it is `node:<alias>`. */
23
+ tokenName: string | null | undefined;
24
+ /** Username resolved from the authenticated token. */
25
+ authenticatedUsername?: string | null;
26
+ /** Caller-supplied `body.from`. */
27
+ requestedFrom: unknown;
28
+ }
29
+
30
+ export type RestIdentityResult =
31
+ | { ok: true; fromSession: string }
32
+ | {
33
+ ok: false;
34
+ error: "from_session_identity_mismatch";
35
+ message: string;
36
+ tokenAlias: string;
37
+ requestedFromSession: string;
38
+ };
39
+
40
+ /** Alias a token is bound to, or null when it is not node-scoped. */
41
+ export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
42
+ if (!token.startsWith("ntok_")) return null;
43
+ if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
44
+ const alias = tokenName.slice("node:".length).trim();
45
+ return alias || null;
46
+ }
47
+
48
+ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
49
+ const alias = nodeAliasForToken(input.token, input.tokenName);
50
+ const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
51
+
52
+ // A network-bound token whose name we cannot resolve to an alias (`node:`,
53
+ // `node: `, or a name that was never node-scoped) is the degenerate case.
54
+ // We know it IS a node credential; we just cannot tell WHICH node. Letting it
55
+ // claim anything is the worst available option, so refuse the claim outright
56
+ // rather than falling through to the unbound path.
57
+ //
58
+ // Production always writes `node:${nodeName}`, so this costs nothing real —
59
+ // but "we couldn't identify you, so you may call yourself anything" is
60
+ // exactly the fail-open shape this change exists to remove.
61
+ if (!alias && input.token.startsWith("ntok_")) {
62
+ if (requested) {
63
+ return {
64
+ ok: false,
65
+ error: "from_session_identity_mismatch",
66
+ message: "network token has no resolvable node alias and may not claim a from_session",
67
+ tokenAlias: "",
68
+ requestedFromSession: requested,
69
+ };
70
+ }
71
+ return { ok: true, fromSession: "api" };
72
+ }
73
+
74
+ // A user-token client normally does not need to repeat its identity in every
75
+ // request. Prefer an explicit, backwards-compatible claim when present;
76
+ // otherwise attribute the task to the authenticated username. Falling back
77
+ // to the transport label `api` is reserved for legacy auth contexts where
78
+ // the server genuinely has no user identity.
79
+ if (!alias) {
80
+ const authenticatedUsername = typeof input.authenticatedUsername === "string"
81
+ ? input.authenticatedUsername.trim()
82
+ : "";
83
+ return { ok: true, fromSession: requested || authenticatedUsername || "api" };
84
+ }
85
+
86
+ // Node token claiming somebody else — refuse rather than silently rewrite,
87
+ // so a misconfigured caller is visible instead of quietly relabelled.
88
+ if (requested && requested !== alias) {
89
+ return {
90
+ ok: false,
91
+ error: "from_session_identity_mismatch",
92
+ message: "network token from_session does not match token-bound node alias",
93
+ tokenAlias: alias,
94
+ requestedFromSession: requested,
95
+ };
96
+ }
97
+
98
+ // Node token, no claim or a matching one: bind to the token's own alias.
99
+ return { ok: true, fromSession: alias };
100
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Stable REST row projections.
3
+ *
4
+ * These lists deliberately mirror the fields already exposed by the public
5
+ * endpoints before #311. Keeping them in one place makes the wire contract
6
+ * independent from future ALTER TABLE migrations: adding a storage-only
7
+ * column must never silently add a REST response key.
8
+ */
9
+ export const NETWORK_REST_COLUMNS = [
10
+ "network_id", "network_name", "owner_id", "description", "settings",
11
+ "created_at", "updated_at", "visibility", "max_members",
12
+ ] as const;
13
+
14
+ export const SESSION_REST_COLUMNS = [
15
+ "resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
16
+ "project_dir", "version", "status", "task", "output", "progress", "score",
17
+ "cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
18
+ "disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
19
+ "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
20
+ "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
+ "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
+ "external_schedules",
23
+ ] as const;
24
+
25
+ export const AUDIT_LOG_REST_COLUMNS = [
26
+ "id", "user_id", "username", "action", "target_type", "target_id", "detail",
27
+ "ip", "network_id", "created_at",
28
+ ] as const;
29
+
30
+ export const TASK_EVENT_REST_COLUMNS = [
31
+ "id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
32
+ "created_at", "network_id",
33
+ ] as const;
34
+
35
+ export const TASK_REST_COLUMNS = [
36
+ "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
37
+ "status", "content", "result", "in_reply_to", "requires_response", "scope",
38
+ "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
39
+ "network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
40
+ ] as const;
41
+
42
+ export const COMPLETION_REST_COLUMNS = [
43
+ "id", "session_name", "task", "result", "artifacts", "score",
44
+ "duration_minutes", "network_id", "completed_at",
45
+ ] as const;
46
+
47
+ // Storage projection for scheduled-task rows. `created_by` and
48
+ // `schedule_json` are intentionally selected for server-side auth/decoding,
49
+ // then removed by decodeRow before the REST response is serialized.
50
+ export const SCHEDULED_TASK_STORAGE_COLUMNS = [
51
+ "schedule_id", "network_id", "created_by", "name", "target_node_id",
52
+ "target_alias", "task_content", "priority", "schedule_type", "schedule_json",
53
+ "timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
54
+ "last_run_at", "revision", "created_at", "updated_at",
55
+ ] as const;
56
+
57
+ export function sqlColumns(columns: readonly string[], qualifier?: string): string {
58
+ return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
59
+ }
60
+
61
+ export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
62
+ export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
63
+ export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
64
+ export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
65
+ export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
66
+ export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
67
+ export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);
@@ -0,0 +1,133 @@
1
+ // #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的
2
+ // 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们:
3
+ //
4
+ // resolveRestNetworkScope 1 canRestWriteNetwork 1
5
+ // singleNetworkId 2 addNetworkScope 1
6
+ // getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0
7
+ //
8
+ // 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
9
+ // 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
10
+ // 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有
11
+ // 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。
12
+ //
13
+ // 🔴 这条规则的两半都要钉:
14
+ // - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员
15
+ // 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修;
16
+ // - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被
17
+ // 指定的网络里**,而调用方看到的是成功。
18
+ //
19
+ // 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。
20
+ // 所以每一条收紧断言都配了正控。
21
+ //
22
+ // Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts
23
+
24
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
25
+ import { db } from "./db.js";
26
+ import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
27
+
28
+ const NET_A = "net_819_a";
29
+ const NET_B = "net_819_b";
30
+ const U_SINGLE = "u819_single"; // 只属于 NET_A
31
+ const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B
32
+ const U_NONE = "u819_none"; // 一个都不属于
33
+ const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE];
34
+
35
+ function cleanup() {
36
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
37
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
38
+ for (const u of ALL_USERS) {
39
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
40
+ }
41
+ }
42
+
43
+ function seed() {
44
+ for (const u of ALL_USERS) {
45
+ db.run(
46
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
47
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
48
+ [u, u],
49
+ );
50
+ }
51
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
53
+ const member = (u: string, net: string, role: string) =>
54
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
55
+ member(U_SINGLE, NET_A, "owner");
56
+ member(U_MULTI, NET_A, "member");
57
+ member(U_MULTI, NET_B, "owner");
58
+ }
59
+
60
+ beforeEach(() => { cleanup(); seed(); });
61
+ afterAll(() => { cleanup(); });
62
+
63
+ /** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */
64
+ const ADMIN_SCOPE: RestNetworkScope = { networkIds: null };
65
+ const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids });
66
+ const ctx = (userId: string) => ({ userId, networkId: null });
67
+
68
+ describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => {
69
+ test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => {
70
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A);
71
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A);
72
+ });
73
+
74
+ test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => {
75
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull();
76
+ });
77
+
78
+ // 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」,
79
+ // 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系
80
+ // 回退分支,而 U_SINGLE 恰好只有一个成员关系。
81
+ //
82
+ // 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行,
83
+ // **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied
84
+ // to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。
85
+ //
86
+ // 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回
87
+ // networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。
88
+ //
89
+ // 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [],
90
+ // 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住:
91
+ // 当前行为,以及那条让它安全的前提。
92
+ test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => {
93
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A);
94
+ });
95
+
96
+ test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => {
97
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull();
98
+ // 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。
99
+ const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true);
100
+ expect(adminScope.networkIds).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("#819 admin 的全局读作用域不能替写入决定网络", () => {
105
+ test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => {
106
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A);
107
+ });
108
+
109
+ test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => {
110
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull();
111
+ });
112
+
113
+ test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => {
114
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull();
115
+ });
116
+
117
+ test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => {
118
+ // 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。
119
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull();
120
+ });
121
+
122
+ test("没有 authCtx + 全局作用域 → null", () => {
123
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("#819 作用域优先于成员关系回退", () => {
128
+ test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => {
129
+ // 归属由作用域决定,不由「他碰巧属于哪个网络」决定。
130
+ // 如果实现把两者的优先级搞反,这条会返回 NET_A。
131
+ expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B);
132
+ });
133
+ });
@@ -0,0 +1,235 @@
1
+ // app#225 — 节点规则文件远程读写:走真 MCP in-process transport 的端到端。
2
+ //
3
+ // 验的是 #225 验收里 hub 能验的那几条:
4
+ // 1. 客户端只用 node_id 就能发起读/写;工具的 inputSchema 里**没有**任何
5
+ // 路径/文件名字段(验收第 5 条「不能借路径参数写任意文件」在 hub 侧的形状)。
6
+ // 2. 节点用网络 token + alias 拉到的请求里也没有路径字段;ack 后客户端轮询拿到内容。
7
+ // 3. SEC-1:跨网络节点发不了、别的节点 ack 不了、用户 token 拉不了。
8
+ // 4. 单飞 + 超时回收。
9
+ //
10
+ // 跑法:cd server && COMMHUB_DB=/tmp/rules.db bun test src/rules-file-transport.test.ts
11
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { db } from "./db.js";
16
+ import { registerTools } from "./tools.js";
17
+
18
+ const NET = "net_rules_file";
19
+ const OTHER_NET = "net_rules_file_other";
20
+ const USER = "u_rules_file_owner";
21
+ const NODE_ID = "node_rules_file_claude";
22
+ const NODE_ALIAS = "rules-file-claude";
23
+ const OTHER_NODE_ID = "node_rules_file_other";
24
+ const OTHER_NODE_ALIAS = "rules-file-other";
25
+ const NODE_TOKEN = "tok_rules_file_node";
26
+ const OTHER_NODE_TOKEN = "tok_rules_file_other_node";
27
+
28
+ function cleanup() {
29
+ for (const net of [NET, OTHER_NET]) {
30
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
31
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
32
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
33
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
34
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
35
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
36
+ }
37
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
38
+ }
39
+
40
+ function seedWorld() {
41
+ db.run(
42
+ `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [USER, USER],
44
+ );
45
+ for (const net of [NET, OTHER_NET]) {
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
47
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
48
+ }
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
51
+ [NODE_ID, NODE_ALIAS, NODE_ALIAS, NET],
52
+ );
53
+ db.run(
54
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
55
+ [OTHER_NODE_ID, OTHER_NODE_ALIAS, OTHER_NODE_ALIAS, OTHER_NET],
56
+ );
57
+ db.run(
58
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
59
+ [NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_rules_file_node"],
60
+ );
61
+ db.run(
62
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
63
+ [OTHER_NODE_TOKEN, USER, OTHER_NET, `node:${OTHER_NODE_ALIAS}`, "hash_rules_file_other"],
64
+ );
65
+ }
66
+
67
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
68
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_file_user" };
69
+ const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
70
+ const asOtherNode: Identity = { net: OTHER_NET, alias: OTHER_NODE_ALIAS, isNetworkToken: true, tokenId: OTHER_NODE_TOKEN };
71
+
72
+ async function connect(id: Identity) {
73
+ const server = new McpServer({ name: "rules-file-test", version: "1" });
74
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
75
+ const client = new Client({ name: "rules-file-client", version: "1" });
76
+ const [ct, st] = InMemoryTransport.createLinkedPair();
77
+ await server.connect(st);
78
+ await client.connect(ct);
79
+ const call = async (name: string, args: Record<string, unknown>) => {
80
+ const r: any = await client.callTool({ name, arguments: args });
81
+ const first = r.content?.[0];
82
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
83
+ return JSON.parse(first.text);
84
+ };
85
+ const close = async () => { await client.close(); await server.close(); };
86
+ return { client, server, call, close };
87
+ }
88
+
89
+ beforeEach(() => { cleanup(); seedWorld(); });
90
+ afterAll(cleanup);
91
+
92
+ describe("app#225 rules file — schema has no path", () => {
93
+ test("read/write tool inputs are node id + content only; no path / file_name / dir field anywhere", async () => {
94
+ // 🔴 不走 client.listTools():main 上 report_status / send_desktop_message /
95
+ // update_node_config / create_node 四个存量工具的 zod 形状让 SDK 的
96
+ // toJSONSchema 抛 `schema._zod`,listTools 整个失败(与本 PR 无关,实验见
97
+ // PR 正文)。这里直接看 McpServer 登记表里的 zod shape —— 它就是 hub 校验
98
+ // 入参用的那份,比 JSON schema 更接近真相。
99
+ const u = await connect(asUser);
100
+ try {
101
+ const reg: Record<string, any> = (u.server as any)._registeredTools;
102
+ const shapeKeys = (name: string): string[] => {
103
+ expect(reg[name], name).toBeDefined();
104
+ const schema = reg[name].inputSchema;
105
+ const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
106
+ return Object.keys(shape).sort();
107
+ };
108
+ expect(shapeKeys("read_node_rules_file")).toEqual(["child_node_id", "network_id", "node_id"]);
109
+ expect(shapeKeys("write_node_rules_file")).toEqual(["child_node_id", "content", "network_id", "node_id"]);
110
+ expect(shapeKeys("get_rules_file_request")).toEqual([]);
111
+ // ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
112
+ expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
113
+ expect(shapeKeys("get_rules_file_result")).toEqual(["network_id", "request_id"]);
114
+ for (const name of ["read_node_rules_file", "write_node_rules_file", "get_rules_file_request", "get_rules_file_result"]) {
115
+ for (const k of shapeKeys(name)) expect(k, `${name}.${k}`).not.toMatch(/path|dir|file|cwd/i);
116
+ }
117
+ } finally { await u.close(); }
118
+ }, 20_000);
119
+ });
120
+
121
+ describe("app#225 rules file — read round trip", () => {
122
+ test("user enqueues read → node pulls (no path in request) → acks content → user polls done", async () => {
123
+ const u = await connect(asUser);
124
+ const n = await connect(asNode);
125
+ try {
126
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
127
+ expect(enq).toMatchObject({ ok: true, op: "read" });
128
+ expect(enq.request_id).toMatch(/^rf_/);
129
+
130
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending", op: "read" });
131
+
132
+ const pulled = await n.call("get_rules_file_request", {});
133
+ expect(pulled.ok).toBe(true);
134
+ expect(Object.keys(pulled.request).sort()).toEqual(["op", "request_id"]);
135
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
136
+ // 第二次拉:已经 in_progress,不再重复下发
137
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
138
+
139
+ expect(await n.call("ack_rules_file_request", {
140
+ request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# 规则\n",
141
+ })).toMatchObject({ ok: true, status: "done" });
142
+
143
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
144
+ expect(res).toMatchObject({ ok: true, status: "done", op: "read", file_name: "CLAUDE.md", exists: true, content: "# 规则\n", error: null });
145
+ } finally { await u.close(); await n.close(); }
146
+ }, 20_000);
147
+
148
+ test("write: content travels to the node verbatim; result carries no content back", async () => {
149
+ const u = await connect(asUser);
150
+ const n = await connect(asNode);
151
+ try {
152
+ const body = "# new rules\n\n- 不碰生产 DB\n";
153
+ const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
154
+ expect(enq).toMatchObject({ ok: true, op: "write" });
155
+ const pulled = await n.call("get_rules_file_request", {});
156
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: body });
157
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
158
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
159
+ expect(res).toMatchObject({ ok: true, status: "done", op: "write", file_name: "CLAUDE.md" });
160
+ expect(res.content).toBeUndefined();
161
+ } finally { await u.close(); await n.close(); }
162
+ }, 20_000);
163
+
164
+ test("node failure is surfaced with its reason", async () => {
165
+ const u = await connect(asUser);
166
+ const n = await connect(asNode);
167
+ try {
168
+ const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "x" });
169
+ await n.call("get_rules_file_request", {});
170
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "failed", file_name: "CLAUDE.md", error: "EACCES: permission denied" });
171
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "failed", error: "EACCES: permission denied" });
172
+ } finally { await u.close(); await n.close(); }
173
+ }, 20_000);
174
+ });
175
+
176
+ describe("app#225 rules file — SEC-1 and lifecycle", () => {
177
+ test("cross-network node cannot be targeted", async () => {
178
+ const u = await connect(asUser);
179
+ try {
180
+ expect(await u.call("read_node_rules_file", { node_id: OTHER_NODE_ID })).toMatchObject({ ok: false, error: "cross_network_node" });
181
+ expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", OTHER_NODE_ID)?.c).toBe(0);
182
+ } finally { await u.close(); }
183
+ }, 20_000);
184
+
185
+ test("a node in another network can neither pull nor ack this node's request", async () => {
186
+ const u = await connect(asUser);
187
+ const o = await connect(asOtherNode);
188
+ try {
189
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
190
+ expect(await o.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
191
+ expect(await o.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
192
+ expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.status).toBe("pending");
193
+ } finally { await u.close(); await o.close(); }
194
+ }, 20_000);
195
+
196
+ test("user token (not a network token) cannot use the node-side pull/ack", async () => {
197
+ const u = await connect(asUser);
198
+ try {
199
+ expect(await u.call("get_rules_file_request", {})).toMatchObject({ ok: false, error: "network_token_required" });
200
+ expect(await u.call("ack_rules_file_request", { request_id: "rf_nope", status: "done" })).toMatchObject({ ok: false, error: "network_token_required" });
201
+ } finally { await u.close(); }
202
+ }, 20_000);
203
+
204
+ test("single flight: a second request while one is pending is refused; a stale one is superseded", async () => {
205
+ const u = await connect(asUser);
206
+ try {
207
+ const first = await u.call("read_node_rules_file", { node_id: NODE_ID });
208
+ expect(await u.call("read_node_rules_file", { node_id: NODE_ID })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
209
+ db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, first.request_id]);
210
+ const second = await u.call("read_node_rules_file", { node_id: NODE_ID });
211
+ expect(second.ok).toBe(true);
212
+ expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", first.request_id)?.status).toBe("timeout");
213
+ } finally { await u.close(); }
214
+ }, 20_000);
215
+
216
+ test("polling a request the node never answered flips it to timeout with a readable reason", async () => {
217
+ const u = await connect(asUser);
218
+ try {
219
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
220
+ db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, enq.request_id]);
221
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
222
+ expect(res).toMatchObject({ ok: true, status: "timeout" });
223
+ expect(res.error).toMatch(/did not answer/);
224
+ } finally { await u.close(); }
225
+ }, 20_000);
226
+
227
+ test("oversized content is refused before anything is queued", async () => {
228
+ const u = await connect(asUser);
229
+ try {
230
+ const r: any = await u.client.callTool({ name: "write_node_rules_file", arguments: { node_id: NODE_ID, content: "x".repeat(256 * 1024 + 1) } });
231
+ expect(r.isError).toBe(true);
232
+ expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", NODE_ID)?.c).toBe(0);
233
+ } finally { await u.close(); }
234
+ }, 20_000);
235
+ });