@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70

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 (162) 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/agent-access.ts +267 -0
  8. package/src/agent-acl-http.test.ts +510 -0
  9. package/src/agent-acl-slice3-http.test.ts +267 -0
  10. package/src/alias-filter.test.ts +72 -0
  11. package/src/alias-filter.ts +32 -0
  12. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  13. package/src/api-host-supervisors-fallback.test.ts +271 -0
  14. package/src/api-nodes-shape.test.ts +26 -10
  15. package/src/auth-sessions-http.test.ts +250 -0
  16. package/src/auth-validate.test.ts +5 -1
  17. package/src/auth.ts +348 -29
  18. package/src/avatar-validate.test.ts +163 -0
  19. package/src/avatar-validate.ts +103 -0
  20. package/src/blocked-exit-on-reply.test.ts +122 -0
  21. package/src/boot-side-effect.test.ts +124 -0
  22. package/src/config-apply-sec1.test.ts +107 -0
  23. package/src/config-apply-validate.test.ts +92 -0
  24. package/src/config-apply-validate.ts +81 -3
  25. package/src/create-node-blocked-gate.test.ts +356 -0
  26. package/src/create-node-tool-schema.test.ts +39 -0
  27. package/src/create-node-validate.test.ts +89 -1
  28. package/src/create-node-validate.ts +89 -7
  29. package/src/create-node-workdir.test.ts +255 -0
  30. package/src/create-node.ts +51 -1
  31. package/src/dashboard-slash-routing-http.test.ts +107 -0
  32. package/src/db-adapter-guard.test.ts +85 -0
  33. package/src/db-adapter.ts +64 -23
  34. package/src/db-timestamp.test.ts +38 -0
  35. package/src/db-timestamp.ts +15 -0
  36. package/src/db.ts +762 -33
  37. package/src/delivered-without-push.test.ts +140 -0
  38. package/src/desktop-message-seam.test.ts +150 -0
  39. package/src/desktop-message.test.ts +368 -0
  40. package/src/ext-token-shared.test.ts +60 -0
  41. package/src/ext-validation-regression.test.ts +131 -0
  42. package/src/external-schedule-edits-http.test.ts +321 -0
  43. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  44. package/src/external-schedule-edits.ts +327 -0
  45. package/src/file-download-authz-dev-open.test.ts +135 -0
  46. package/src/file-download-authz.test.ts +404 -0
  47. package/src/file-download-date-bucket.test.ts +436 -0
  48. package/src/file-network-scope-dev-open.test.ts +97 -0
  49. package/src/file-network-scope.test.ts +732 -0
  50. package/src/health-redaction.test.ts +642 -0
  51. package/src/http-gzip.test.ts +73 -0
  52. package/src/http-gzip.ts +60 -0
  53. package/src/hub-timestamp.test.ts +38 -0
  54. package/src/hub-timestamp.ts +27 -0
  55. package/src/human-dm.ts +149 -0
  56. package/src/inbox-count-sse.test.ts +310 -0
  57. package/src/inbox-count.ts +29 -0
  58. package/src/index.ts +15 -2331
  59. package/src/lifecycle-guard.ts +50 -0
  60. package/src/mcp-write-network-resolution.test.ts +429 -0
  61. package/src/messages-ack-by-agent-http.test.ts +142 -0
  62. package/src/network-name-http.test.ts +75 -0
  63. package/src/network-scope.ts +224 -0
  64. package/src/node-attrs-validate.ts +88 -0
  65. package/src/node-attrs.test.ts +217 -0
  66. package/src/node-create-request-status-http.test.ts +92 -0
  67. package/src/node-files-transport.test.ts +344 -0
  68. package/src/node-id-alias.test.ts +188 -0
  69. package/src/node-lifecycle-controllable.test.ts +64 -0
  70. package/src/node-lifecycle-controllable.ts +51 -0
  71. package/src/node-logs-transport.test.ts +263 -0
  72. package/src/node-request-retention.test.ts +281 -0
  73. package/src/node-request-retention.ts +120 -0
  74. package/src/node-skills-transport.test.ts +209 -0
  75. package/src/node-token-privilege-http.test.ts +149 -0
  76. package/src/observer-avatar-http.test.ts +429 -0
  77. package/src/observer-push.test.ts +252 -0
  78. package/src/package-version-consistency.test.ts +25 -0
  79. package/src/peer-reply-atomic.test.ts +455 -0
  80. package/src/peer-reply-capability.test.ts +70 -0
  81. package/src/push.ts +204 -7
  82. package/src/redact-tokens.test.ts +65 -0
  83. package/src/redact-tokens.ts +40 -0
  84. package/src/rename.ts +7 -2
  85. package/src/reply-target-derivation.test.ts +96 -0
  86. package/src/report-status-host-schema.test.ts +211 -0
  87. package/src/report-status-resume-id-handover.test.ts +150 -0
  88. package/src/require-explicit-test-db.ts +9 -0
  89. package/src/requirement-tags.ts +16 -0
  90. package/src/requirements-http.test.ts +708 -0
  91. package/src/requirements-mcp.test.ts +142 -0
  92. package/src/requirements-migrate.ts +83 -0
  93. package/src/requirements-priority-lowest.test.ts +114 -0
  94. package/src/requirements-upgrade-e2e.test.ts +126 -0
  95. package/src/requirements-upgrade-migration.test.ts +85 -0
  96. package/src/requirements.ts +865 -0
  97. package/src/resolve-port.test.ts +57 -0
  98. package/src/resolve-port.ts +44 -0
  99. package/src/response-charset.test.ts +130 -0
  100. package/src/rest-explicit-columns-http.test.ts +216 -0
  101. package/src/rest-identity.test.ts +129 -0
  102. package/src/rest-identity.ts +100 -0
  103. package/src/rest-projections.ts +68 -0
  104. package/src/rest-write-network-resolution.test.ts +133 -0
  105. package/src/restricted-files.ts +73 -0
  106. package/src/retention.ts +10 -1
  107. package/src/rules-file-duplicate-alias.test.ts +234 -0
  108. package/src/rules-file-session-target.test.ts +207 -0
  109. package/src/rules-file-transport.test.ts +237 -0
  110. package/src/runtime-label.test.ts +22 -0
  111. package/src/runtime-label.ts +21 -0
  112. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  113. package/src/scheduled-run-terminal.test.ts +268 -0
  114. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  115. package/src/scheduled-tasks-http.test.ts +574 -0
  116. package/src/scheduled-tasks.ts +596 -0
  117. package/src/send-dedup-request-id.test.ts +229 -0
  118. package/src/send-reply-agent-warning.test.ts +202 -0
  119. package/src/send-reply-alias-optional.test.ts +131 -0
  120. package/src/send-reply-attachments.test.ts +458 -0
  121. package/src/send_dedup.test.ts +54 -0
  122. package/src/send_dedup.ts +52 -6
  123. package/src/serve-error.test.ts +38 -0
  124. package/src/serve-error.ts +27 -0
  125. package/src/server.ts +4279 -0
  126. package/src/shared/external-schedule-contract.ts +60 -0
  127. package/src/side-thread-command-transport.test.ts +302 -0
  128. package/src/side-thread-command-transport.ts +318 -0
  129. package/src/side-thread-http-integration.test.ts +233 -0
  130. package/src/side-thread-http.ts +504 -0
  131. package/src/side-thread-production.test.ts +72 -0
  132. package/src/side-thread-production.ts +118 -0
  133. package/src/side-thread.test.ts +1067 -0
  134. package/src/side-thread.ts +2495 -0
  135. package/src/skillhub-http.test.ts +92 -0
  136. package/src/start-node.test.ts +109 -0
  137. package/src/stop-delete-node.test.ts +1164 -0
  138. package/src/task-auth-origin-http.test.ts +91 -0
  139. package/src/task-auth-origin.test.ts +24 -0
  140. package/src/task-auth-origin.ts +7 -0
  141. package/src/task-consumption.test.ts +482 -0
  142. package/src/task-diagnostic-http.test.ts +149 -0
  143. package/src/task-diagnostic.test.ts +115 -0
  144. package/src/task-diagnostic.ts +118 -0
  145. package/src/task-idempotency-mcp.test.ts +142 -0
  146. package/src/task-idempotency.test.ts +60 -0
  147. package/src/task-idempotency.ts +75 -0
  148. package/src/task-lifecycle-watcher.test.ts +204 -0
  149. package/src/task-lifecycle-watcher.ts +49 -0
  150. package/src/task-network-resolution-http.test.ts +141 -0
  151. package/src/tasks-pagination-http.test.ts +141 -0
  152. package/src/tools-list-json-schema.test.ts +66 -0
  153. package/src/tools.ts +3445 -257
  154. package/src/update-provider.test.ts +65 -0
  155. package/src/upload-early-reject-guard.test.ts +155 -0
  156. package/src/uploads-http.test.ts +97 -15
  157. package/src/uploads.test.ts +66 -0
  158. package/src/uploads.ts +130 -5
  159. package/src/user-inbox-by-agent-http.test.ts +161 -0
  160. package/src/user-inbox-netid-notnull.test.ts +122 -0
  161. package/src/user-inbox-read-http.test.ts +223 -0
  162. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,163 @@
1
+ // #462 — unit tests for the avatar_url XSS boundary (avatar-validate.ts).
2
+ //
3
+ // The validator's whole job is to keep hostile values out of a dashboard
4
+ // <img src>; every rejection case here is a concrete injection shape.
5
+ // Live-gate check: replace validateAvatarUrl with `return { ok: true,
6
+ // value: String(raw) }` and the javascript:/data:/control-char tests go
7
+ // red.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { MAX_AVATAR_URL_LENGTH, validateAvatarUrl } from "./avatar-validate";
11
+
12
+ describe("#462 validateAvatarUrl", () => {
13
+ test("accepts plain https URL", () => {
14
+ const r = validateAvatarUrl("https://cdn.example.com/a/b.png");
15
+ expect(r).toEqual({ ok: true, value: "https://cdn.example.com/a/b.png" });
16
+ });
17
+
18
+ test("accepts http URL and trims surrounding whitespace", () => {
19
+ const r = validateAvatarUrl(" http://example.com/x.jpg ");
20
+ expect(r).toEqual({ ok: true, value: "http://example.com/x.jpg" });
21
+ });
22
+
23
+ test("accepts URL with query string", () => {
24
+ const r = validateAvatarUrl("https://example.com/img?size=64&v=2");
25
+ expect(r.ok).toBe(true);
26
+ });
27
+
28
+ test("null / undefined / empty / whitespace-only → clear (value null)", () => {
29
+ expect(validateAvatarUrl(null)).toEqual({ ok: true, value: null });
30
+ expect(validateAvatarUrl(undefined)).toEqual({ ok: true, value: null });
31
+ expect(validateAvatarUrl("")).toEqual({ ok: true, value: null });
32
+ expect(validateAvatarUrl(" ")).toEqual({ ok: true, value: null });
33
+ });
34
+
35
+ test("rejects non-string types", () => {
36
+ expect(validateAvatarUrl(42).ok).toBe(false);
37
+ expect(validateAvatarUrl({ url: "https://x" }).ok).toBe(false);
38
+ expect(validateAvatarUrl(["https://x"]).ok).toBe(false);
39
+ expect(validateAvatarUrl(true).ok).toBe(false);
40
+ });
41
+
42
+ test("rejects javascript: pseudo-protocol", () => {
43
+ expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
44
+ expect(validateAvatarUrl("JavaScript:alert(1)").ok).toBe(false);
45
+ expect(validateAvatarUrl("JAVASCRIPT:alert(document.cookie)").ok).toBe(false);
46
+ });
47
+
48
+ test("rejects javascript: smuggled via embedded tab/newline (URL() would strip them)", () => {
49
+ expect(validateAvatarUrl("java\tscript:alert(1)").ok).toBe(false);
50
+ expect(validateAvatarUrl("java\nscript:alert(1)").ok).toBe(false);
51
+ expect(validateAvatarUrl("java\rscript:alert(1)").ok).toBe(false);
52
+ });
53
+
54
+ test("rejects data: / vbscript: / file: / blob: and other non-http protocols", () => {
55
+ expect(validateAvatarUrl("data:text/html,<script>alert(1)</script>").ok).toBe(false);
56
+ expect(validateAvatarUrl("data:image/png;base64,iVBORw0KGgo=").ok).toBe(false);
57
+ expect(validateAvatarUrl("vbscript:msgbox(1)").ok).toBe(false);
58
+ expect(validateAvatarUrl("file:///etc/passwd").ok).toBe(false);
59
+ expect(validateAvatarUrl("blob:https://example.com/uuid").ok).toBe(false);
60
+ expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
61
+ });
62
+
63
+ test("rejects protocol-relative / non-URL / schemeless strings", () => {
64
+ // SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
65
+ // (/avatars/<name>.<ext>) are now ACCEPTED — that case moved to the
66
+ // dedicated relative-branch suite below. Everything here stays rejected.
67
+ expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
68
+ expect(validateAvatarUrl("not a url").ok).toBe(false);
69
+ expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
70
+ });
71
+
72
+ test("rejects embedded whitespace and control characters", () => {
73
+ expect(validateAvatarUrl("https://example.com/a b.png").ok).toBe(false);
74
+ expect(validateAvatarUrl("https://example.com/a\u0000.png").ok).toBe(false);
75
+ expect(validateAvatarUrl("https://example.com/a\u009f.png").ok).toBe(false);
76
+ });
77
+
78
+ test("normalizes to parsed.href: markup-hostile chars come out percent-encoded (finding 3)", () => {
79
+ const r1 = validateAvatarUrl('https://example.com/a"b.png');
80
+ expect(r1).toEqual({ ok: true, value: "https://example.com/a%22b.png" });
81
+ const r2 = validateAvatarUrl("https://example.com/</script><script>.png");
82
+ expect(r2.ok).toBe(true);
83
+ if (r2.ok) {
84
+ expect(r2.value).not.toContain("<");
85
+ expect(r2.value).not.toContain(">");
86
+ }
87
+ const r3 = validateAvatarUrl("https://example.com/a`b.png");
88
+ expect(r3.ok).toBe(true);
89
+ if (r3.ok) expect(r3.value).not.toContain("`");
90
+ });
91
+
92
+ test("rejects values whose href still carries attribute-breaking chars (single quote)", () => {
93
+ // WHATWG URL leaves ' un-encoded in the PATH component — href alone
94
+ // doesn't neutralize it there, so the validator must refuse.
95
+ expect(validateAvatarUrl("https://example.com/a'b.png").ok).toBe(false);
96
+ // In the QUERY component ' IS percent-encoded by href → accepted,
97
+ // but the stored value must carry no raw quote.
98
+ const q = validateAvatarUrl("https://example.com/x.png?a='1'");
99
+ expect(q.ok).toBe(true);
100
+ if (q.ok) expect(q.value).not.toContain("'");
101
+ });
102
+
103
+ test("rejects URLs with embedded credentials (userinfo)", () => {
104
+ expect(validateAvatarUrl("https://user:pass@example.com/a.png").ok).toBe(false);
105
+ expect(validateAvatarUrl("https://user@example.com/a.png").ok).toBe(false);
106
+ expect(validateAvatarUrl("http://:secret@example.com/a.png").ok).toBe(false);
107
+ });
108
+
109
+ test("enforces the length cap", () => {
110
+ const base = "https://example.com/";
111
+ const ok = base + "a".repeat(MAX_AVATAR_URL_LENGTH - base.length);
112
+ expect(validateAvatarUrl(ok).ok).toBe(true);
113
+ expect(validateAvatarUrl(ok + "a").ok).toBe(false);
114
+ });
115
+ });
116
+
117
+ // ── Same-origin relative branch (通信龙 裁定: relax to portable pool paths;
118
+ // absolute URLs would weld one hostname into the DB across multi-origin
119
+ // entry points). Each direction is its OWN test so any single guard
120
+ // being deleted turns its OWN assertion red (no merged catch-all).
121
+ describe("validateAvatarUrl — same-origin relative pool paths", () => {
122
+ test("accepts a pool path: /avatars/avatar-09.webp", () => {
123
+ const r = validateAvatarUrl("/avatars/avatar-09.webp");
124
+ expect(r).toEqual({ ok: true, value: "/avatars/avatar-09.webp" });
125
+ });
126
+
127
+ test("accepts png and svg pool files", () => {
128
+ expect(validateAvatarUrl("/avatars/intern_avatar.png")).toEqual({ ok: true, value: "/avatars/intern_avatar.png" });
129
+ expect(validateAvatarUrl("/avatars/team-logo.svg")).toEqual({ ok: true, value: "/avatars/team-logo.svg" });
130
+ });
131
+
132
+ test("rejects protocol-relative //host (classic same-origin bypass)", () => {
133
+ const r = validateAvatarUrl("//evil.com/x.png");
134
+ expect(r.ok).toBe(false);
135
+ if (!r.ok) expect(r.reason).toContain("protocol-relative");
136
+ });
137
+
138
+ test("rejects javascript: scheme (still, after the relax)", () => {
139
+ expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
140
+ });
141
+
142
+ test("rejects non-/avatars/ absolute paths (/etc/passwd)", () => {
143
+ expect(validateAvatarUrl("/etc/passwd").ok).toBe(false);
144
+ });
145
+
146
+ test("rejects non-whitelisted extensions under /avatars/", () => {
147
+ expect(validateAvatarUrl("/avatars/payload.html").ok).toBe(false);
148
+ });
149
+
150
+ test("rejects nested/traversal paths (charset forbids further slashes)", () => {
151
+ expect(validateAvatarUrl("/avatars/../secrets.webp").ok).toBe(false);
152
+ expect(validateAvatarUrl("/avatars/sub/x.webp").ok).toBe(false);
153
+ });
154
+
155
+ test("rejects percent-encoding inside the filename (no encoded surprises)", () => {
156
+ expect(validateAvatarUrl("/avatars/a%2f..%2fx.webp").ok).toBe(false);
157
+ });
158
+
159
+ test("absolute http(s) branch is UNTOUCHED by the relax (positive control)", () => {
160
+ const r = validateAvatarUrl("https://example.com/pic.png");
161
+ expect(r.ok).toBe(true);
162
+ });
163
+ });
@@ -0,0 +1,103 @@
1
+ // #462 — validator for the user-set custom avatar URL (nodes.avatar_url).
2
+ //
3
+ // The value ends up in an <img src> on every dashboard client, so this is
4
+ // an XSS trust boundary: protocol MUST be http/https (rejects javascript:,
5
+ // data:, vbscript:, file:, blob:, …), no whitespace/control characters,
6
+ // bounded length. Pure function, unit-tested in avatar-validate.test.ts.
7
+
8
+ export const MAX_AVATAR_URL_LENGTH = 2048;
9
+
10
+ export type AvatarValidation =
11
+ | { ok: true; value: string | null }
12
+ | { ok: false; reason: string };
13
+
14
+ /**
15
+ * Normalize + validate an untrusted avatar_url patch value.
16
+ *
17
+ * null / undefined / "" / whitespace-only → { ok, value: null } (clear)
18
+ * same-origin pool path /avatars/<name>.<ext> → { ok, value: trimmed }
19
+ * valid absolute http(s) URL → { ok, value: normalized }
20
+ * anything else → { ok: false, reason }
21
+ *
22
+ * Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is
23
+ * reached through several origins (localhost dev, public domains) — an
24
+ * absolute URL would weld ONE hostname into the DB and break the image
25
+ * for every other entry point; a same-origin relative path is portable.
26
+ * 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" —
27
+ * "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL
28
+ * the browser resolves to evil.com. Hence: single leading slash with the
29
+ * SECOND char not "/", then an exact value-set match (the /avatars/ pool
30
+ * prefix + extension whitelist), not a shape match.
31
+ */
32
+
33
+ // Exact allowed set for same-origin values: the dashboard's designed pool
34
+ // under /avatars/. Filename charset excludes "/" (no traversal, no nested
35
+ // paths) and "%" (no encoded surprises); extensions mirror the actual
36
+ // pool contents. Widen ONLY by extending this list deliberately.
37
+ const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/;
38
+ export function validateAvatarUrl(raw: unknown): AvatarValidation {
39
+ if (raw === null || raw === undefined) return { ok: true, value: null };
40
+ if (typeof raw !== "string") {
41
+ return { ok: false, reason: "avatar_url must be a string or null" };
42
+ }
43
+ const trimmed = raw.trim();
44
+ if (trimmed.length === 0) return { ok: true, value: null };
45
+ if (trimmed.length > MAX_AVATAR_URL_LENGTH) {
46
+ return { ok: false, reason: `avatar_url must be ≤ ${MAX_AVATAR_URL_LENGTH} chars` };
47
+ }
48
+ // Reject embedded whitespace + C0/C1 control chars outright. URL()
49
+ // silently strips some of these (e.g. tabs/newlines), which would let
50
+ // "java\tscript:" style payloads normalize into a hostile protocol —
51
+ // so this check must run BEFORE parsing, on the raw trimmed string.
52
+ if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
53
+ return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
54
+ }
55
+ // Same-origin relative branch (runs AFTER the whitespace/control gate
56
+ // above — that check protects this branch too). Order of the two tests
57
+ // matters for the reason string, not for safety: the exact-set regex
58
+ // alone already rejects "//…" (second char is "/", first segment must
59
+ // literally be "avatars"), the explicit double-slash check just names
60
+ // the classic bypass in its own words.
61
+ if (trimmed.startsWith("/")) {
62
+ if (trimmed.startsWith("//")) {
63
+ return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" };
64
+ }
65
+ if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) {
66
+ return { ok: false, reason: "relative avatar_url must match /avatars/<name>.(webp|png|svg)" };
67
+ }
68
+ return { ok: true, value: trimmed };
69
+ }
70
+ // Absolute http(s) branch — unchanged from #462 (do not touch while
71
+ // relaxing: 通信龙 裁定 condition 3).
72
+ let parsed: URL;
73
+ try {
74
+ parsed = new URL(trimmed);
75
+ } catch {
76
+ return { ok: false, reason: "avatar_url must be an absolute URL" };
77
+ }
78
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
79
+ return { ok: false, reason: "avatar_url protocol must be http or https" };
80
+ }
81
+ // Embedded credentials (https://user:pass@host/…) would be persisted,
82
+ // leaked to the avatar host on every render, and can spoof the visible
83
+ // origin in UIs. Reject outright rather than silently stripping — the
84
+ // caller should know their input carried secrets (通信龙 review round-2
85
+ // follow-up).
86
+ if (parsed.username || parsed.password) {
87
+ return { ok: false, reason: "avatar_url must not contain credentials (userinfo)" };
88
+ }
89
+ // Persist the NORMALIZED href, not the raw input: href percent-encodes
90
+ // markup-hostile characters (" < > ` → %22 %3C %3E %60), so what lands
91
+ // in the DB is already safe to interpolate into an <img src> attribute.
92
+ // The dashboard renderer lives in a different package this repo cannot
93
+ // audit — the server must not bet on it escaping (审查修复 per 通信龙
94
+ // #461/#462 review, finding 3).
95
+ const normalized = parsed.href;
96
+ // Belt-and-braces: reject anything href does NOT encode that could
97
+ // still break out of an attribute context (WHATWG URL leaves ' and \
98
+ // untouched in some positions).
99
+ if (/["'<>\\`]/.test(normalized)) {
100
+ return { ok: false, reason: "avatar_url contains characters not allowed in a URL" };
101
+ }
102
+ return { ok: true, value: normalized };
103
+ }
@@ -0,0 +1,122 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #1548 —— 一个活着、能收发任务的节点可以在名册里永远显示 `blocked`:
7
+ // 只有 report_completion 会把 sessions.status 拉回 idle,而现在大多数 agent 用
8
+ // send_reply(终态)结束任务、用 send_task 派活 —— 这两条路径此前都不碰 sessions.status。
9
+ // 「它刚回了我一条终态消息 / 刚派了一条任务」本身就是最强的活性证据。
10
+ // 只出 `blocked`;`working` 可能真在忙别的任务,不碰(保守)。
11
+ const NET_ID = "net_1548_blocked";
12
+ const USER_ID = "u_1548";
13
+ const NODE_ID = "node_1548_agent";
14
+ const AGENT_ALIAS = "agent-1548";
15
+ const DISPATCHER_NODE_ID = "node_1548_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher-1548";
17
+ const AGENT_TOK_ID = "tok_1548";
18
+
19
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
20
+
21
+ function cleanup() {
22
+ for (const [sql, params] of [
23
+ ["DELETE FROM tasks WHERE network_id = ?1", [NET_ID]],
24
+ ["DELETE FROM inbox WHERE network_id = ?1", [NET_ID]],
25
+ ["DELETE FROM task_events WHERE network_id = ?1", [NET_ID]],
26
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
27
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
28
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
29
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
30
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
31
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
32
+ ] as const) {
33
+ try { db.run(sql, params as any); } catch {}
34
+ }
35
+ }
36
+ beforeEach(cleanup);
37
+ afterAll(cleanup);
38
+
39
+ function seed(agentStatus: string) {
40
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
41
+ 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]);
42
+ db.run(
43
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
44
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
45
+ [USER_ID, NET_ID],
46
+ );
47
+ for (const [nodeId, alias] of [[DISPATCHER_NODE_ID, DISPATCHER_ALIAS], [NODE_ID, AGENT_ALIAS]] as const) {
48
+ db.run(
49
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
51
+ [nodeId, alias, alias, NET_ID, `host-${alias}`],
52
+ );
53
+ }
54
+ db.run(
55
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
56
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
57
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
58
+ );
59
+ db.run(
60
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
62
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, agentStatus, NODE_ID, NET_ID],
63
+ );
64
+ db.run(
65
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
66
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
67
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
68
+ );
69
+ }
70
+ function makeTask(): string {
71
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
72
+ db.run(
73
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
74
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
75
+ [id, DISPATCHER_ALIAS, AGENT_ALIAS, NODE_ID, NET_ID],
76
+ );
77
+ return id;
78
+ }
79
+ function buildHandlers(): Record<string, ToolHandler> {
80
+ const server = new McpServer({ name: "test", version: "0" }) as any;
81
+ const tools: Record<string, ToolHandler> = {};
82
+ const origTool = server.tool.bind(server);
83
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
84
+ const origRegisterTool = server.registerTool?.bind(server);
85
+ if (origRegisterTool) {
86
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
87
+ }
88
+ registerTools(server, undefined, NET_ID, USER_ID, AGENT_ALIAS, true, AGENT_TOK_ID);
89
+ return tools;
90
+ }
91
+ async function call(handler: ToolHandler, args: any): Promise<any> {
92
+ const r = await handler(args);
93
+ return JSON.parse(r.content[0]!.text);
94
+ }
95
+ function agentStatus(): string {
96
+ return (db.get("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", [AGENT_ALIAS, NET_ID]) as { status: string }).status;
97
+ }
98
+
99
+ describe("#1548 blocked has an exit: a terminal reply or an outbound task proves the agent is alive", () => {
100
+ test("terminal send_reply from a blocked agent puts it back to idle", async () => {
101
+ seed("blocked");
102
+ const tools = buildHandlers();
103
+ const taskId = makeTask();
104
+ const r = await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
105
+ expect(r.ok, JSON.stringify(r)).toBe(true);
106
+ expect(agentStatus()).toBe("idle");
107
+ });
108
+ test("send_task from a blocked agent puts it back to idle", async () => {
109
+ seed("blocked");
110
+ const tools = buildHandlers();
111
+ const r = await call(tools.send_task!, { alias: DISPATCHER_ALIAS, task: "ping", priority: "normal", from_session: AGENT_ALIAS, network_id: NET_ID });
112
+ expect(r.ok, JSON.stringify(r)).toBe(true);
113
+ expect(agentStatus()).toBe("idle");
114
+ });
115
+ test("a working agent is left alone (it may be busy with another task)", async () => {
116
+ seed("working");
117
+ const tools = buildHandlers();
118
+ const taskId = makeTask();
119
+ await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
120
+ expect(agentStatus()).toBe("working");
121
+ });
122
+ });
@@ -0,0 +1,124 @@
1
+ // #438 corrective — regression gate for the two boot-path failure modes:
2
+ //
3
+ // 1. import side effect: `import("./server.js")` must NOT bind any port.
4
+ // Pre-corrective (7fa3ad35, when this module was still index.ts with
5
+ // module-level `export const server = bootServer()`) this fails:
6
+ // witnessed-red showed the probe port EADDRINUSE right after import.
7
+ // The run-entry shim src/index.ts is the ONLY module whose import
8
+ // boots — by design, documented there — and is deliberately not
9
+ // imported anywhere in the test suite.
10
+ // 2. silent no-op double start: `startHub()` is single-shot and must
11
+ // REJECT a second call observably — "I thought I started it but
12
+ // nothing happened" is the same failure family as the
13
+ // import.meta.main incident (#438 review).
14
+ //
15
+ // Test order inside this file matters: the no-side-effect probe must run
16
+ // BEFORE anything calls startHub in this process. bun executes tests in
17
+ // declaration order, and no other suite calls startHub.
18
+
19
+ import { describe, expect, test } from "bun:test";
20
+
21
+ const PROBE_PORT = 18500 + Math.floor(Math.random() * 500);
22
+
23
+ describe("#438 corrective — importing server.ts is side-effect-free", () => {
24
+ test("importing server.js binds nothing (probe port stays free)", async () => {
25
+ // Pin the module's default PORT to the probe so that IF an import
26
+ // side effect ever regresses, it binds exactly the port we probe.
27
+ process.env.PORT = process.env.PORT || String(PROBE_PORT);
28
+ const probe = Number(process.env.PORT) || PROBE_PORT;
29
+ await import("./server.js");
30
+ // If import had booted a server, this bind would throw EADDRINUSE.
31
+ let listener: any = null;
32
+ expect(() => {
33
+ listener = Bun.listen({ hostname: "127.0.0.1", port: probe, socket: { data() {} } });
34
+ }).not.toThrow();
35
+ listener?.stop?.(true);
36
+ });
37
+ });
38
+
39
+ describe("#476 — import alone must let the process exit", () => {
40
+ test("a subprocess that only imports server.js exits by itself (no module-level timers hold the loop)", () => {
41
+ // Witnessed-red: pre-#476 (main 541a80aa) this child is killed by the
42
+ // timeout with a non-zero exit — two module-level setIntervals
43
+ // (rate-limit sweep + DB-writing task patrol) held the event loop
44
+ // after a bare import. Post-#476 both live inside startHub, so the
45
+ // child exits 0 on its own. Runs in a subprocess because the parent
46
+ // test process has its own handles and can't observe loop-emptiness.
47
+ const child = Bun.spawnSync({
48
+ cmd: ["bun", "-e", 'await import("./src/server.js");'],
49
+ cwd: `${import.meta.dir}/..`,
50
+ env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-476-probe.db" },
51
+ timeout: 10_000,
52
+ });
53
+ expect(child.exitCode).toBe(0);
54
+ }, 15_000);
55
+
56
+ test("the moved task patrol actually FIRES under startHub (not just registered-looking)", () => {
57
+ // Fake-gate guard for the #476 move: stub patrolExpiredTasks to a
58
+ // no-op (or forget to register it in startHub) and this goes red.
59
+ // Runs in a subprocess because startHub is single-shot per process
60
+ // and the patrol period is shrunk via env for observability.
61
+ const script = `
62
+ process.env.COMMHUB_TASK_PATROL_MS = "100";
63
+ const { startHub } = await import("./src/server.js");
64
+ const { db } = await import("./src/db.js");
65
+ const hub = startHub({ port: 0, hostname: "127.0.0.1" });
66
+ db.run("DELETE FROM tasks WHERE task_id = 't476_patrol'");
67
+ db.run("INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, created_at, expires_at) " +
68
+ "VALUES ('t476_patrol', 'a', 'b', 'normal', 'delivered', 'x', datetime('now'), datetime('now', '-1 minute'))");
69
+ await new Promise((r) => setTimeout(r, 600));
70
+ const row = db.get("SELECT status FROM tasks WHERE task_id = 't476_patrol'");
71
+ console.log("PATROL_RESULT:" + (row ? row.status : "missing"));
72
+ db.run("DELETE FROM tasks WHERE task_id = 't476_patrol'");
73
+ hub.stop(true);
74
+ process.exit(0);
75
+ `;
76
+ const child = Bun.spawnSync({
77
+ cmd: ["bun", "-e", script],
78
+ cwd: `${import.meta.dir}/..`,
79
+ env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-476-patrol.db" },
80
+ timeout: 15_000,
81
+ });
82
+ const out = new TextDecoder().decode(child.stdout);
83
+ expect(out).toContain("PATROL_RESULT:expired");
84
+ }, 20_000);
85
+ });
86
+
87
+ describe("#438 corrective — startHub is explicit, single-shot, observable", () => {
88
+ test("startHub boots a live hub; second call throws with first boot's coordinates; bootServer stays available", async () => {
89
+ const { startHub, bootServer, getStartedHub } = await import("./server.js") as any;
90
+ expect(getStartedHub()).toBeNull();
91
+
92
+ const hub = startHub({ port: 0, hostname: "127.0.0.1" });
93
+ try {
94
+ expect(hub.port).toBeGreaterThan(0);
95
+ expect(getStartedHub()?.server).toBe(hub);
96
+
97
+ // Really listening — the production liveness probe, not just a
98
+ // non-null handle.
99
+ const health = await fetch(`http://127.0.0.1:${hub.port}/health`);
100
+ expect(health.status).toBe(200);
101
+ expect(((await health.json()) as any).ok).toBe(true);
102
+
103
+ // Second call must be an OBSERVABLE rejection, not a silent no-op,
104
+ // and must tell the caller where the first boot lives.
105
+ let thrown: Error | null = null;
106
+ try {
107
+ startHub({ port: 0 });
108
+ } catch (e: any) {
109
+ thrown = e;
110
+ }
111
+ expect(thrown).not.toBeNull();
112
+ expect(String(thrown!.message)).toContain("single-shot");
113
+ expect(String(thrown!.message)).toContain(String(hub.port));
114
+
115
+ // Throwaway instances stay available after startHub.
116
+ const extra = bootServer({ port: 0, hostname: "127.0.0.1" });
117
+ expect(extra.port).toBeGreaterThan(0);
118
+ expect(extra.port).not.toBe(hub.port);
119
+ extra.stop(true);
120
+ } finally {
121
+ hub.stop(true);
122
+ }
123
+ });
124
+ });
@@ -721,3 +721,110 @@ describe("SEC-1 — update lifecycle: single-flight + cross-network pending look
721
721
  expect(inFlight).toBeDefined();
722
722
  });
723
723
  });
724
+
725
+ // ── #260 P5 codex-catch regression: channels finalize ─────────────
726
+ describe("finalizePendingMatchingUpdates — #260 P5 channels content-match", () => {
727
+ function insertPendingUpdate(opts: {
728
+ update_id?: string;
729
+ node_id: string;
730
+ network_id: string;
731
+ patch: any;
732
+ apply_mode: "hot" | "restart" | "restart_only";
733
+ status?: "pending" | "restarting";
734
+ }): string {
735
+ const id = opts.update_id ?? `cu_${uuidv4()}`;
736
+ db.run(
737
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
738
+ [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
739
+ );
740
+ return id;
741
+ }
742
+
743
+ test("channels-only patch: snapshot BEFORE apply (first boot) must NOT finalize", () => {
744
+ const nid = insertNode({ alias: "n-chan-1", network_id: "netA" });
745
+ const uid = insertPendingUpdate({
746
+ node_id: nid, network_id: "netA",
747
+ // Note the empty flags object — the pre-fix bug was that this
748
+ // trivially matched the flags loop and hub falsely finalized.
749
+ patch: { flags: {}, channels: ["feishu"] },
750
+ apply_mode: "restart",
751
+ });
752
+ // Node's startup snapshot BEFORE it consumed the update — no
753
+ // channels forked yet, so buildConfigSnapshot emits channels=[].
754
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
755
+ expect(r.finalizedCount).toBe(0);
756
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
757
+ });
758
+
759
+ test("channels-only patch: snapshot AFTER apply (restart complete) finalizes", () => {
760
+ const nid = insertNode({ alias: "n-chan-2", network_id: "netA" });
761
+ const uid = insertPendingUpdate({
762
+ node_id: nid, network_id: "netA",
763
+ patch: { flags: {}, channels: ["feishu"] },
764
+ apply_mode: "restart",
765
+ });
766
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu"] });
767
+ expect(r.finalizedCount).toBe(1);
768
+ expect(r.finalizedIds).toContain(uid);
769
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
770
+ });
771
+
772
+ test("channels set-equality: order doesn't matter", () => {
773
+ const nid = insertNode({ alias: "n-chan-3", network_id: "netA" });
774
+ insertPendingUpdate({
775
+ node_id: nid, network_id: "netA",
776
+ patch: { flags: {}, channels: ["telegram", "feishu"] },
777
+ apply_mode: "restart",
778
+ });
779
+ // Snapshot order swapped — still matches.
780
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu", "telegram"] });
781
+ expect(r.finalizedCount).toBe(1);
782
+ });
783
+
784
+ test("channels: [] disable-all — finalize when snapshot also [] (post-restart)", () => {
785
+ const nid = insertNode({ alias: "n-chan-4", network_id: "netA" });
786
+ insertPendingUpdate({
787
+ node_id: nid, network_id: "netA",
788
+ patch: { flags: {}, channels: [] },
789
+ apply_mode: "restart",
790
+ });
791
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
792
+ expect(r.finalizedCount).toBe(1);
793
+ });
794
+
795
+ test("channels: [] disable-all — do NOT finalize when snapshot still has channels", () => {
796
+ const nid = insertNode({ alias: "n-chan-5", network_id: "netA" });
797
+ insertPendingUpdate({
798
+ node_id: nid, network_id: "netA",
799
+ patch: { flags: {}, channels: [] },
800
+ apply_mode: "restart",
801
+ });
802
+ // Node hasn't restarted yet — old telegram worker still forked.
803
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["telegram"] });
804
+ expect(r.finalizedCount).toBe(0);
805
+ });
806
+
807
+ test("snapshot omits channels field entirely (pre-P5 agent-node) → NOT finalized on channels-carrying patch", () => {
808
+ const nid = insertNode({ alias: "n-chan-6", network_id: "netA" });
809
+ insertPendingUpdate({
810
+ node_id: nid, network_id: "netA",
811
+ patch: { flags: {}, channels: ["feishu"] },
812
+ apply_mode: "restart",
813
+ });
814
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {} }); // no channels key
815
+ expect(r.finalizedCount).toBe(0);
816
+ });
817
+
818
+ test("model + channels compound match: both must satisfy", () => {
819
+ const nid = insertNode({ alias: "n-chan-7", network_id: "netA" });
820
+ insertPendingUpdate({
821
+ node_id: nid, network_id: "netA",
822
+ patch: { model: "opus-new", flags: {}, channels: ["telegram"] },
823
+ apply_mode: "restart",
824
+ });
825
+ // Wrong channels → not finalized even though model matches.
826
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: [] }).finalizedCount).toBe(0);
827
+ // Both match → finalized.
828
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: ["telegram"] }).finalizedCount).toBe(1);
829
+ });
830
+ });