@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -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 +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -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 +933 -36
- 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 +330 -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-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -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 +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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 +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -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 +101 -0
- package/src/node-id-alias.test.ts +190 -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 +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -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 +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -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 +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -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 +349 -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 +259 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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 +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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 +264 -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 +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -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 +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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 +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -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 +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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
|
@@ -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,121 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { preflightCodexStart } from "../../agent-node/src/runtime/adopt-codex-start-preflight.js";
|
|
4
|
+
|
|
5
|
+
process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/binding-http-")}/hub.db`;
|
|
6
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "binding-master-fixture";
|
|
7
|
+
let db:any, hub:ReturnType<typeof Bun.serve>, base:string, owner:any, other:any;
|
|
8
|
+
const daemon="n_binding_daemon", child="n_binding_child";
|
|
9
|
+
let token:string, wrong:string, cross:string, ordinary:string, revoked:string;
|
|
10
|
+
async function mcp(credential:string,name:string,args:Record<string,unknown>={}) {
|
|
11
|
+
const res=await fetch(`${base}/mcp`, {method:"POST",headers:{Authorization:`Bearer ${credential}`,
|
|
12
|
+
"Content-Type":"application/json",Accept:"application/json, text/event-stream","MCP-Protocol-Version":"2025-03-26"},
|
|
13
|
+
body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name,arguments:args}})});
|
|
14
|
+
const raw=await res.text();
|
|
15
|
+
if(res.status!==200)return {ok:false,http_status:res.status};
|
|
16
|
+
const lines=raw.split("\n").filter(s=>s.startsWith("data:"));
|
|
17
|
+
const payload=JSON.parse(lines.length?lines.at(-1)!.slice(5):raw);
|
|
18
|
+
return payload.result?.content?.[0]?.text ? JSON.parse(payload.result.content[0].text) : {ok:false,error:payload.error};
|
|
19
|
+
}
|
|
20
|
+
const list=(t=token)=>mcp(t,"list_my_children");
|
|
21
|
+
const request=()=>mcp(owner.token,"request_adopt_node",{node_id:child,daemon_node_id:daemon,workdir:"/fixture/project"});
|
|
22
|
+
const ack=(id:string,status="adopted")=>mcp(token,"ack_adopt_request",{request_id:id,status});
|
|
23
|
+
const unadopt=()=>mcp(owner.token,"unadopt_node",{node_id:child});
|
|
24
|
+
async function activate(){const r=await request();expect(r.ok).toBe(true);expect((await ack(r.request_id)).status).toBe("active");return r.request_id;}
|
|
25
|
+
beforeAll(async()=>{
|
|
26
|
+
({db}=await import("./db.js"));
|
|
27
|
+
const {register}=await import("./auth.js");
|
|
28
|
+
owner=register("binding_owner","Fixture-Strong1!");other=register("binding_other","Fixture-Strong1!");
|
|
29
|
+
expect(owner.ok&&other.ok).toBe(true);
|
|
30
|
+
db.run("UPDATE users SET role='user' WHERE user_id=?1",[other.user.user_id]);
|
|
31
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')",[owner.network_id,other.user.user_id]);
|
|
32
|
+
const {generateNetworkToken,hashToken}=await import("./db.js");
|
|
33
|
+
function node(id:string,network:string,user:string,supervisor=true){
|
|
34
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')",
|
|
35
|
+
[id,network,user,JSON.stringify(supervisor?{role:"host_supervisor",daemon_capabilities:{adopt_capable:true}}:{})]);
|
|
36
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))",[id,network]);
|
|
37
|
+
const raw=generateNetworkToken();
|
|
38
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)",[id,user,network,`node:${id}`,hashToken(raw)]);
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
token=node(daemon,owner.network_id,owner.user.user_id);
|
|
42
|
+
wrong=node("n_binding_other",owner.network_id,other.user.user_id);
|
|
43
|
+
cross=node("n_binding_cross",other.network_id,other.user.user_id);
|
|
44
|
+
node(child,owner.network_id,owner.user.user_id,false);
|
|
45
|
+
ordinary=generateNetworkToken();revoked=generateNetworkToken();
|
|
46
|
+
for(const [id,raw,name,dead] of [["ordinary",ordinary,"general",null],["revoked",revoked,`node:${daemon}`,"2020-01-01"]])
|
|
47
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,revoked_at) VALUES(?1,?2,?3,'network',?4,?5,?6)",[id,owner.user.user_id,owner.network_id,name,hashToken(raw as string),dead]);
|
|
48
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,lifecycle_state) VALUES('node_binding_created','created','created',?1,'active')",[owner.network_id]);
|
|
49
|
+
db.run("INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token) VALUES('cr_binding_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'fixture')",[daemon,owner.network_id]);
|
|
50
|
+
const {bootServer}=await import("./server.js");hub=bootServer({port:0,hostname:"127.0.0.1"});base=`http://127.0.0.1:${hub.port}`;
|
|
51
|
+
},60000);
|
|
52
|
+
afterAll(()=>hub?.stop(true));
|
|
53
|
+
|
|
54
|
+
test("generation projection and unchanged created row",async()=>{
|
|
55
|
+
const id=await activate();
|
|
56
|
+
const r=await list();expect(r.ok).toBe(true);
|
|
57
|
+
expect((await mcp(token,"get_adopt_request",{request_id:id})).error).toBe("request_not_pending");
|
|
58
|
+
expect(r.children.find((n:any)=>n.child_node_id===child)).toEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted",binding_request_id:id});
|
|
59
|
+
expect(r.children.find((n:any)=>n.child_node_id==="node_binding_created")).toEqual({child_node_id:"node_binding_created",alias:"created",lifecycle_state:"active"});
|
|
60
|
+
// Old consumers continue reading exactly the same fields; additive only.
|
|
61
|
+
expect(r.children.map(({child_node_id,alias,lifecycle_state,managed}:any)=>({child_node_id,alias,lifecycle_state,managed}))).toContainEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted"});
|
|
62
|
+
expect((await unadopt()).ok).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
test("token boundary denies humans general revoked and other owners",async()=>{
|
|
65
|
+
await activate();
|
|
66
|
+
for(const t of [owner.token,other.token,ordinary,revoked])expect((await list(t)).ok).toBe(false);
|
|
67
|
+
for(const t of [wrong,cross]){const r=await list(t);expect(r.ok).toBe(true);expect(r.children).toEqual([]);}
|
|
68
|
+
expect((await unadopt()).ok).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
test("child node token sees an empty list, not its parent binding",async()=>{
|
|
71
|
+
const id=await activate();
|
|
72
|
+
try {
|
|
73
|
+
const issued=await fetch(`${base}/api/auth/node-token`,{method:"POST",
|
|
74
|
+
headers:{Authorization:`Bearer ${owner.token}`,"Content-Type":"application/json"},
|
|
75
|
+
body:JSON.stringify({network_id:owner.network_id,node_name:child,node_id:child})});
|
|
76
|
+
expect(issued.status).toBe(200);
|
|
77
|
+
const credential=await issued.json() as any;
|
|
78
|
+
expect(credential.ok).toBe(true);
|
|
79
|
+
expect(typeof credential.token).toBe("string");
|
|
80
|
+
// Same owner and network do not confer the parent's daemon identity.
|
|
81
|
+
expect((await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id).toBe(id);
|
|
82
|
+
expect(await list(credential.token)).toEqual({ok:true,count:0,children:[]});
|
|
83
|
+
} finally {expect((await unadopt()).ok).toBe(true);}
|
|
84
|
+
});
|
|
85
|
+
test("network predicate rejects corrupted cross-network binding sentinel",async()=>{
|
|
86
|
+
const id=await activate();
|
|
87
|
+
// Deliberately inconsistent test DB: daemon stays in A, binding and child in B.
|
|
88
|
+
// This makes the network predicate independently load-bearing, HTTP still real.
|
|
89
|
+
try {
|
|
90
|
+
db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[other.network_id,id]);
|
|
91
|
+
db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);
|
|
92
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
93
|
+
} finally {
|
|
94
|
+
db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[owner.network_id,id]);
|
|
95
|
+
db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
test("node join rejects mismatched node network sentinel",async()=>{
|
|
99
|
+
await activate();
|
|
100
|
+
try {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);}
|
|
101
|
+
finally {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();}
|
|
102
|
+
});
|
|
103
|
+
test("non-active bindings never project generation",async()=>{
|
|
104
|
+
const r=await request();expect(r.ok).toBe(true);
|
|
105
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
106
|
+
expect((await ack(r.request_id,"refused")).status).toBe("refused");
|
|
107
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
108
|
+
await activate();await unadopt();expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
test("revocation rotates generation and old acknowledgements cannot revive",async()=>{
|
|
111
|
+
const r1=await activate();await unadopt();
|
|
112
|
+
expect((await ack(r1)).error).toBe("request_not_pending");
|
|
113
|
+
const r2=await request();expect(r2.ok).toBe(true);expect(r2.request_id).not.toBe(r1);
|
|
114
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
115
|
+
expect((await ack(r2.request_id)).status).toBe("active");
|
|
116
|
+
const current=(await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id;
|
|
117
|
+
expect(current).toBe(r2.request_id);expect(current).not.toBe(r1);
|
|
118
|
+
// Real HTTP authority feeds the existing daemon gate; later probes must not run.
|
|
119
|
+
for(const authority of [current,undefined])await expect(preflightCodexStart({request_id:r1} as any,{} as any,{} as any,authority,()=>true)).rejects.toThrow("adopt_codex_binding_generation_unproven");
|
|
120
|
+
await unadopt();
|
|
121
|
+
});
|
|
@@ -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
|
+
});
|