@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.141
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-department-http.test.ts +148 -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 +423 -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 +604 -51
- 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/chain-attempt-dedup.test.ts +447 -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 +49 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +199 -7
- package/src/create-node-validate.ts +232 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +88 -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 +109 -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 +1119 -42
- 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 +276 -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/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -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/host-supervisor-identity-http.test.ts +152 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-catalog-read-http.test.ts +187 -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/human-inbox.ts +42 -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/list-host-supervisors.test.ts +5 -4
- 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 +1083 -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 +135 -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-secret-rest-http.test.ts +190 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-identity.ts +35 -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 +442 -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/password-network-tokens.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 +38 -10
- 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 +184 -0
- package/src/report-status-secret-keys.test.ts +140 -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 +131 -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 +357 -0
- package/src/requirements-stats.ts +195 -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 +141 -0
- package/src/rest-projections.ts +70 -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-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -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-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -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 +312 -0
- package/src/scheduled-stuck.ts +186 -0
- package/src/scheduled-tasks-http.test.ts +679 -0
- package/src/scheduled-tasks.ts +885 -0
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +139 -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 +5398 -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 -2
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-activity.ts +60 -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 +4008 -355
- 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,158 @@
|
|
|
1
|
+
// 节点令牌不继承签发者的账号权限与 Hub 管理员身份。
|
|
2
|
+
//
|
|
3
|
+
// 生产上几乎所有节点令牌(ntok_)都由管理员签发,resolveToken 把它解析成那个管理员用户。
|
|
4
|
+
// 以前一部分端点直接看 `resolved.user.role === "admin"` 或把令牌当成用户本人,于是任何一个节点
|
|
5
|
+
// 都能:列全部用户、读服务端日志与审计日志、给签发者再签用户令牌 / 节点令牌、改签发者资料与密码、
|
|
6
|
+
// 建 / 改 / 删网络、管成员与邀请、读别的网络详情、看全部网络的 SSE 拓扑。
|
|
7
|
+
// 这里每一条在修复前(main)都是红的;管理员自己的用户令牌作为正控,照常可用。
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { register, createNetwork, createNetworkTokenForNode } from "./auth.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
|
|
16
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-ntok-priv-"));
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
const PW = "NtokPrivPassw0rd!";
|
|
20
|
+
let adminToken = "", adminId = "", NET = "", OTHER_NET = "", ntok = "";
|
|
21
|
+
|
|
22
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
23
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { ...auth(token), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
34
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
35
|
+
process.env.HOST = "127.0.0.1";
|
|
36
|
+
const admin = register(`ntok_admin_${Date.now()}`, PW);
|
|
37
|
+
adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
|
|
38
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
39
|
+
const other = createNetwork(adminId, "ntok-other");
|
|
40
|
+
OTHER_NET = (other as any).network_id;
|
|
41
|
+
const minted = createNetworkTokenForNode(adminId, NET, "ntok-node", "node_ntok_priv");
|
|
42
|
+
expect(minted.ok).toBe(true);
|
|
43
|
+
ntok = minted.token!;
|
|
44
|
+
register(`ntok_someone_${Date.now()}`, PW);
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try { hub?.stop?.(true); } catch {}
|
|
52
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("hub-admin powers need a user token", () => {
|
|
56
|
+
test("GET /api/users", async () => {
|
|
57
|
+
expect((await call(ntok, "GET", "/api/users")).status).toBe(403);
|
|
58
|
+
expect((await call(adminToken, "GET", "/api/users")).status).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
test("GET /api/server-logs", async () => {
|
|
61
|
+
expect((await call(ntok, "GET", "/api/server-logs")).status).toBe(403);
|
|
62
|
+
expect((await call(adminToken, "GET", "/api/server-logs")).status).toBe(200);
|
|
63
|
+
});
|
|
64
|
+
test("GET /api/audit-log", async () => {
|
|
65
|
+
expect((await call(ntok, "GET", "/api/audit-log")).status).toBe(403);
|
|
66
|
+
expect((await call(adminToken, "GET", "/api/audit-log")).status).toBe(200);
|
|
67
|
+
});
|
|
68
|
+
test("GET /api/networks/:id of a network the token is not bound to", async () => {
|
|
69
|
+
expect((await call(ntok, "GET", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
70
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}`)).status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
test("GET /api/stats/sse is limited to the bound network", async () => {
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const sub = await fetch(`${BASE}/events/some-agent?network_id=${OTHER_NET}`, { headers: auth(adminToken), signal: ctrl.signal }).catch(() => null);
|
|
75
|
+
const r = await call(ntok, "GET", "/api/stats/sse");
|
|
76
|
+
ctrl.abort();
|
|
77
|
+
expect(r.status).toBe(200);
|
|
78
|
+
expect(Object.keys(r.body.sessions).every((k) => k.split(":").includes(NET))).toBe(true);
|
|
79
|
+
expect(JSON.stringify(r.body)).not.toContain(OTHER_NET);
|
|
80
|
+
void sub;
|
|
81
|
+
});
|
|
82
|
+
test("POST /api/admin/users and agent-grants", async () => {
|
|
83
|
+
expect((await call(ntok, "POST", "/api/admin/users", { username: "ntok_made", password: PW })).status).toBe(403);
|
|
84
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members/${adminId}/agent-grants`)).status).toBe(403);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("account and network management need a user token", () => {
|
|
89
|
+
test("token management: cannot list, mint user tokens, mint node tokens or revoke", async () => {
|
|
90
|
+
expect((await call(ntok, "GET", "/api/auth/tokens")).status).toBe(403);
|
|
91
|
+
const minted = await call(ntok, "POST", "/api/auth/tokens", { name: "escalate" });
|
|
92
|
+
expect(minted.status).toBe(403);
|
|
93
|
+
expect(minted.body.token).toBeUndefined();
|
|
94
|
+
expect((await call(ntok, "POST", "/api/auth/node-token", { network_id: OTHER_NET, node_name: "x" })).status).toBe(403);
|
|
95
|
+
expect((await call(ntok, "DELETE", "/api/auth/tokens/tok_whatever")).status).toBe(403);
|
|
96
|
+
expect((await call(adminToken, "GET", "/api/auth/tokens")).status).toBe(200);
|
|
97
|
+
});
|
|
98
|
+
test("cannot edit the owner's profile or password", async () => {
|
|
99
|
+
expect((await call(ntok, "PUT", "/api/auth/me", { display_name: "pwned" })).status).toBe(403);
|
|
100
|
+
expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", adminId)?.display_name).not.toBe("pwned");
|
|
101
|
+
expect((await call(ntok, "POST", "/api/auth/password", { old_password: PW, new_password: "AnotherPassw0rd!" })).status).toBe(403);
|
|
102
|
+
});
|
|
103
|
+
test("cannot create, rename or delete networks", async () => {
|
|
104
|
+
expect((await call(ntok, "POST", "/api/networks", { name: "ntok-made" })).status).toBe(403);
|
|
105
|
+
expect((await call(ntok, "PUT", `/api/networks/${OTHER_NET}`, { name: "renamed" })).status).toBe(403);
|
|
106
|
+
expect((await call(ntok, "DELETE", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
107
|
+
expect(db.get("SELECT 1 FROM networks WHERE network_id = ?1", OTHER_NET)).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
test("cannot manage members, invites or join", async () => {
|
|
110
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members`)).status).toBe(403);
|
|
111
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/members`, { user_id: "u_x" })).status).toBe(403);
|
|
112
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/invite`, { role: "admin" })).status).toBe(403);
|
|
113
|
+
expect((await call(ntok, "POST", "/api/networks/join", { invite_code: "inv_x" })).status).toBe(403);
|
|
114
|
+
expect((await call(adminToken, "GET", `/api/networks/${NET}/members`)).status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
test("renames: a node token may only rename ITSELF in its bound network", async () => {
|
|
117
|
+
// 别的网络 / 别的节点 → user_token_required
|
|
118
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: OTHER_NET, old_alias: "ntok-node", new_alias: "b" })).status).toBe(403);
|
|
119
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_other_node', 'other-node', 'idle', ?1)`, [NET]);
|
|
120
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "b" })).status).toBe(403);
|
|
121
|
+
// 另一个节点(管理员用户令牌)开的事务,本节点不能提交
|
|
122
|
+
const foreign = await call(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "other-node-2" });
|
|
123
|
+
expect(foreign.body.ok).toBe(true);
|
|
124
|
+
expect((await call(ntok, "POST", "/api/node-rename/commit", { txn_id: foreign.body.txn_id })).status).toBe(403);
|
|
125
|
+
expect((await call(adminToken, "POST", "/api/node-rename/abort", { txn_id: foreign.body.txn_id })).body.ok).toBe(true);
|
|
126
|
+
// 自己:照常(anet node rename 就是这么用的)
|
|
127
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_self_node', 'ntok-node', 'idle', ?1)`, [NET]);
|
|
128
|
+
const mine = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: "ntok-node-2" });
|
|
129
|
+
expect(mine.body.ok).toBe(true);
|
|
130
|
+
expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
test("rename rejects line breaks, tabs and other control characters before reserving an alias", async () => {
|
|
133
|
+
for (const newAlias of ["hidden\nnode", "hidden\tnode", "hidden\u001fnode", "hidden\u007fnode", "hidden\u2028node", "hidden\u2029node"]) {
|
|
134
|
+
const r = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: newAlias });
|
|
135
|
+
expect(r.status).toBe(400);
|
|
136
|
+
expect(r.body).toMatchObject({ ok: false, error: "node_alias_invalid", code: "node_alias_invalid", reason: "control_character" });
|
|
137
|
+
expect(r.body.message).toContain("control character U+");
|
|
138
|
+
}
|
|
139
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM rename_txn WHERE network_id = ?1 AND status = 'prepared'", NET)?.n).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("/api/auth/me says what the credential is", () => {
|
|
144
|
+
test("node token: owner user for compatibility, but credential.kind=node and only the bound network", async () => {
|
|
145
|
+
const r = await call(ntok, "GET", "/api/auth/me");
|
|
146
|
+
expect(r.status).toBe(200);
|
|
147
|
+
expect(r.body.credential).toMatchObject({ kind: "node", network_id: NET, node_alias: "ntok-node", acts_as_owner: false });
|
|
148
|
+
expect(r.body.current_network).toBe(NET);
|
|
149
|
+
expect(r.body.networks.map((n: any) => n.network_id)).toEqual([NET]);
|
|
150
|
+
const u = await call(adminToken, "GET", "/api/auth/me");
|
|
151
|
+
expect(u.body.credential).toEqual({ kind: "user" });
|
|
152
|
+
expect(u.body.networks.map((n: any) => n.network_id).sort()).toEqual([NET, OTHER_NET].sort());
|
|
153
|
+
});
|
|
154
|
+
test("node token keeps its own network powers (still a member): status and tasks read fine", async () => {
|
|
155
|
+
expect((await call(ntok, "GET", "/api/status")).status).toBe(200);
|
|
156
|
+
expect((await call(ntok, "GET", "/api/tasks")).status).toBe(200);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// #461 + #462 — HTTP integration tests against the real Bun.serve hub.
|
|
2
|
+
//
|
|
3
|
+
// Boots a PRIVATE server instance via the #434 seam (`bootServer({ port: 0 })`,
|
|
4
|
+
// same signature as PR #438) and derives BASE from the actual bound port.
|
|
5
|
+
// This keeps the suite alive in aggregate runs: `await import("./server.js")`
|
|
6
|
+
// is a module-cache no-op when another file booted first, so relying on the
|
|
7
|
+
// import side effect + own PORT env leaves every fetch ConnectionRefused
|
|
8
|
+
// while the tests still "run" (通信龙 #461 review, finding 2). Gate:
|
|
9
|
+
// bun test src/uploads-http.test.ts src/observer-avatar-http.test.ts → 0 fail
|
|
10
|
+
//
|
|
11
|
+
// #461: GET /events/network/:id observer stream — membership auth incl.
|
|
12
|
+
// member-removal revocation, summary events for third-party traffic
|
|
13
|
+
// (REST dispatch + MCP send_reply), no content leakage.
|
|
14
|
+
// #462: PUT /api/nodes/:ref/avatar + GET /api/nodes round trip — persistence,
|
|
15
|
+
// href normalization, XSS-shaped rejects, clear semantics, write gate.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
18
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { tmpdir } from "os";
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { register, login, addNetworkMember, removeNetworkMember, createNetworkTokenForNode } from "./auth.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { db } from "./db.js";
|
|
25
|
+
|
|
26
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-obs-avatar-db-")) + "/commhub.db";
|
|
27
|
+
|
|
28
|
+
let BASE = "";
|
|
29
|
+
let privateServer: any = null;
|
|
30
|
+
|
|
31
|
+
let memberToken = "";
|
|
32
|
+
let memberNetworkId = "";
|
|
33
|
+
let memberUserId = "";
|
|
34
|
+
let outsiderToken = "";
|
|
35
|
+
let outsiderUserId = "";
|
|
36
|
+
const TARGET_ALIAS = "obs-target-agent";
|
|
37
|
+
const AVATAR_NODE_ID = "node_avatar_test_1";
|
|
38
|
+
|
|
39
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
40
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
41
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
42
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
// Kept for per-file runs launched without an external COMMHUB_DB (the
|
|
45
|
+
// documented contract still is to set it externally; see db-adapter.ts).
|
|
46
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
47
|
+
process.env.HOST = "127.0.0.1";
|
|
48
|
+
// (#438 corrective: importing server.js is side-effect-free — no
|
|
49
|
+
// default instance, no PORT juggling; only the private bootServer
|
|
50
|
+
// instance below listens.)
|
|
51
|
+
|
|
52
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
53
|
+
const pw = "BootstrapPw123Aa!";
|
|
54
|
+
|
|
55
|
+
const memberName = `obs_member_${suffix}`;
|
|
56
|
+
let r = register(memberName, pw, undefined, "seed");
|
|
57
|
+
if (!r.ok || !r.token) {
|
|
58
|
+
const lr = login(memberName, pw);
|
|
59
|
+
if (lr.token) { memberToken = lr.token; memberNetworkId = lr.network_id ?? ""; }
|
|
60
|
+
} else {
|
|
61
|
+
memberToken = r.token;
|
|
62
|
+
memberNetworkId = r.network_id ?? "";
|
|
63
|
+
}
|
|
64
|
+
expect(memberToken).toBeTruthy();
|
|
65
|
+
expect(memberNetworkId).toBeTruthy();
|
|
66
|
+
memberUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", memberName)?.user_id ?? "";
|
|
67
|
+
expect(memberUserId).toBeTruthy();
|
|
68
|
+
|
|
69
|
+
const outsiderName = `obs_outsider_${suffix}`;
|
|
70
|
+
const r2 = register(outsiderName, pw, undefined, "seed");
|
|
71
|
+
if (r2.ok && r2.token) outsiderToken = r2.token;
|
|
72
|
+
expect(outsiderToken).toBeTruthy();
|
|
73
|
+
outsiderUserId = db.get<any>("SELECT user_id FROM users WHERE username = ?1", outsiderName)?.user_id ?? "";
|
|
74
|
+
expect(outsiderUserId).toBeTruthy();
|
|
75
|
+
|
|
76
|
+
// Target agent session in the member's network, freshly seen → online.
|
|
77
|
+
db.run(
|
|
78
|
+
`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
|
|
79
|
+
VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`,
|
|
80
|
+
[`resume_${suffix}`, TARGET_ALIAS, memberNetworkId]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Node row for the avatar round trip.
|
|
84
|
+
db.run(
|
|
85
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
86
|
+
VALUES (?1, 'avatar-test-node', ?2, ?3, datetime('now'), datetime('now'))`,
|
|
87
|
+
[AVATAR_NODE_ID, TARGET_ALIAS, memberNetworkId]
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Private instance on an OS-assigned port — collision-free by
|
|
91
|
+
// construction (kernel-assigned).
|
|
92
|
+
const mod: any = await import("./server.js");
|
|
93
|
+
privateServer = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
94
|
+
BASE = `http://127.0.0.1:${privateServer.port}`;
|
|
95
|
+
}, 30_000);
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
try { privateServer?.stop?.(true); } catch {}
|
|
99
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function auth(token: string): Record<string, string> {
|
|
103
|
+
return { "Authorization": `Bearer ${token}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read the next SSE data frame from a fetch Response, with timeout. */
|
|
107
|
+
async function readFrame(
|
|
108
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
109
|
+
timeoutMs = 3_000,
|
|
110
|
+
): Promise<Record<string, any>> {
|
|
111
|
+
const decoder = new TextDecoder();
|
|
112
|
+
let buf = "";
|
|
113
|
+
const deadline = Date.now() + timeoutMs;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
const result = await Promise.race([
|
|
116
|
+
reader.read(),
|
|
117
|
+
new Promise<never>((_, rej) =>
|
|
118
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
|
|
119
|
+
),
|
|
120
|
+
]);
|
|
121
|
+
if (result.done) throw new Error("SSE stream ended unexpectedly");
|
|
122
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
123
|
+
const sep = buf.indexOf("\n\n");
|
|
124
|
+
if (sep === -1) continue;
|
|
125
|
+
const rawFrame = buf.slice(0, sep);
|
|
126
|
+
buf = buf.slice(sep + 2);
|
|
127
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
128
|
+
if (!dataLine) continue;
|
|
129
|
+
return JSON.parse(dataLine.slice(6));
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("#461 GET /events/network/:id — auth", () => {
|
|
135
|
+
test("anonymous → 401", async () => {
|
|
136
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`);
|
|
137
|
+
expect(res.status).toBe(401);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("non-member user token → 403", async () => {
|
|
141
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
142
|
+
expect(res.status).toBe(403);
|
|
143
|
+
const body = await res.json() as any;
|
|
144
|
+
expect(body.ok).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("member user token → SSE stream with observer connected frame", async () => {
|
|
148
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
expect(res.headers.get("content-type") || "").toContain("text/event-stream");
|
|
151
|
+
const reader = res.body!.getReader();
|
|
152
|
+
const connected = await readFrame(reader);
|
|
153
|
+
expect(connected.type).toBe("connected");
|
|
154
|
+
expect(connected.observer).toBe(true);
|
|
155
|
+
expect(connected.network_id).toBe(memberNetworkId);
|
|
156
|
+
await reader.cancel();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("membership removal revokes observer access — ntok exploit repro (通信龙 review finding 1)", async () => {
|
|
160
|
+
// Exact repro of the review's exploit: add user C to network A, mint
|
|
161
|
+
// an ntok BOUND to A, remove C from A — the same live ntok must lose
|
|
162
|
+
// the stream. (Before #488 network_members was the only revocation
|
|
163
|
+
// mechanism — removeNetworkMember did not revoke tokens; the old
|
|
164
|
+
// `!role && authCtx.networkId !== observedNetId` OR-form let the
|
|
165
|
+
// token's network binding bypass the membership check (utok would
|
|
166
|
+
// not catch this: its authCtx.networkId is null, so only ntok
|
|
167
|
+
// exercises the vulnerable branch — verified by stubbing the OR
|
|
168
|
+
// back in: THIS test goes red, the utok variants stay green).
|
|
169
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
170
|
+
const added = addNetworkMember(memberNetworkId, outsiderUserId, "member", undefined, { agentAccess: "all" });
|
|
171
|
+
expect(added.ok).toBe(true);
|
|
172
|
+
const minted = createNetworkTokenForNode(outsiderUserId, memberNetworkId, "revocation-probe");
|
|
173
|
+
expect(minted.ok).toBe(true);
|
|
174
|
+
const ntok = minted.token!;
|
|
175
|
+
expect(ntok.startsWith("ntok_")).toBe(true);
|
|
176
|
+
|
|
177
|
+
const whileMember = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
178
|
+
expect(whileMember.status).toBe(200);
|
|
179
|
+
const reader = whileMember.body!.getReader();
|
|
180
|
+
expect((await readFrame(reader)).type).toBe("connected");
|
|
181
|
+
await reader.cancel();
|
|
182
|
+
|
|
183
|
+
const removed = removeNetworkMember(memberNetworkId, outsiderUserId);
|
|
184
|
+
expect(removed.ok).toBe(true);
|
|
185
|
+
// The review's failing probe: /events/network/A with the network-bound
|
|
186
|
+
// token → must no longer be 200. Since #488 removeNetworkMember revokes
|
|
187
|
+
// the user's tokens for that network and resolveToken rejects a
|
|
188
|
+
// non-member's network token, so the token itself is refused (401).
|
|
189
|
+
const afterRemoval = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
190
|
+
expect(afterRemoval.status).toBe(401);
|
|
191
|
+
// utok of the removed user is denied too.
|
|
192
|
+
const utokAfter = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
193
|
+
expect(utokAfter.status).toBe(403);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("GET /events/users/me — Desktop user stream auth", () => {
|
|
198
|
+
test("anonymous → 401", async () => {
|
|
199
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`);
|
|
200
|
+
expect(res.status).toBe(401);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("ntok is refused for user stream", async () => {
|
|
204
|
+
const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "desktop-ntok-probe");
|
|
205
|
+
expect(minted.ok).toBe(true);
|
|
206
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(minted.token!) });
|
|
207
|
+
expect(res.status).toBe(403);
|
|
208
|
+
const body = await res.json() as any;
|
|
209
|
+
expect(body.error).toBe("user_token_required");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("utok without network_id → 400", async () => {
|
|
213
|
+
const res = await fetch(`${BASE}/events/users/me`, { headers: auth(memberToken) });
|
|
214
|
+
expect(res.status).toBe(400);
|
|
215
|
+
const body = await res.json() as any;
|
|
216
|
+
expect(body.error).toBe("network_id required");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("non-member utok → 403", async () => {
|
|
220
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(outsiderToken) });
|
|
221
|
+
expect(res.status).toBe(403);
|
|
222
|
+
const body = await res.json() as any;
|
|
223
|
+
expect(body.error).toBe("not a member of this network");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("member utok → user stream connected frame keyed to authenticated user", async () => {
|
|
227
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(memberToken) });
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
expect(res.headers.get("content-type") || "").toContain("text/event-stream");
|
|
230
|
+
const reader = res.body!.getReader();
|
|
231
|
+
const connected = await readFrame(reader);
|
|
232
|
+
expect(connected.type).toBe("connected");
|
|
233
|
+
expect(connected.user).toBe(true);
|
|
234
|
+
expect(connected.network_id).toBe(memberNetworkId);
|
|
235
|
+
expect(connected.user_id).toBe(memberUserId);
|
|
236
|
+
await reader.cancel();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("GET /api/messages — client inbox count", () => {
|
|
241
|
+
test("alias filter returns only that inbox and pending_count for unacked rows", async () => {
|
|
242
|
+
const selfAlias = `user-${memberUserId}`;
|
|
243
|
+
db.run(
|
|
244
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
|
|
245
|
+
VALUES (?1, ?2, 'task', 'normal', 'unread self', 'agent-a', 0, ?3, datetime('now'))`,
|
|
246
|
+
[`msg_self_unread_${Date.now()}`, selfAlias, memberNetworkId],
|
|
247
|
+
);
|
|
248
|
+
db.run(
|
|
249
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
|
|
250
|
+
VALUES (?1, ?2, 'task', 'normal', 'read self', 'agent-a', 1, ?3, datetime('now'))`,
|
|
251
|
+
[`msg_self_read_${Date.now()}`, selfAlias, memberNetworkId],
|
|
252
|
+
);
|
|
253
|
+
db.run(
|
|
254
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
|
|
255
|
+
VALUES (?1, 'other-alias', 'task', 'normal', 'other inbox', 'agent-b', 0, ?2, datetime('now'))`,
|
|
256
|
+
[`msg_other_${Date.now()}`, memberNetworkId],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const res = await fetch(`${BASE}/api/messages?alias=${encodeURIComponent(selfAlias)}&network_id=${encodeURIComponent(memberNetworkId)}&limit=20`, {
|
|
260
|
+
headers: auth(memberToken),
|
|
261
|
+
});
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
const body = await res.json() as any;
|
|
264
|
+
expect(body.ok).toBe(true);
|
|
265
|
+
expect(body.pending_count).toBe(1);
|
|
266
|
+
expect(body.messages.length).toBeGreaterThanOrEqual(2);
|
|
267
|
+
expect(body.messages.every((m: any) => m.to_alias === selfAlias)).toBe(true);
|
|
268
|
+
expect(body.messages.some((m: any) => m.content === "other inbox")).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("#461 observer receives third-party traffic summaries", () => {
|
|
273
|
+
test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
|
|
274
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
275
|
+
const reader = res.body!.getReader();
|
|
276
|
+
await readFrame(reader); // connected
|
|
277
|
+
|
|
278
|
+
const secret = `SECRET-TASK-CONTENT-${Date.now()}`;
|
|
279
|
+
const body = { alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId };
|
|
280
|
+
// Human tokens send as their authenticated username; they cannot stand
|
|
281
|
+
// in for an Agent. Keep that refusal and exercise the observer with an
|
|
282
|
+
// actual bound sender token instead of weakening the identity contract.
|
|
283
|
+
const spoof = await fetch(`${BASE}/api/task`, {
|
|
284
|
+
method: "POST", headers: { ...auth(memberToken), "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
285
|
+
});
|
|
286
|
+
expect(spoof.status).toBe(403);
|
|
287
|
+
expect((await spoof.json() as any).error).toBe("from_session_identity_mismatch");
|
|
288
|
+
const sender = createNetworkTokenForNode(memberUserId, memberNetworkId, "third-party-sender", `node_obs_sender_${Date.now()}`);
|
|
289
|
+
expect(sender.ok).toBe(true);
|
|
290
|
+
const dispatch = await fetch(`${BASE}/api/task`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { ...auth(sender.token!), "Content-Type": "application/json" },
|
|
293
|
+
body: JSON.stringify(body),
|
|
294
|
+
});
|
|
295
|
+
expect([200, 202]).toContain(dispatch.status);
|
|
296
|
+
const dispatched = await dispatch.json() as any;
|
|
297
|
+
expect(dispatched.task_id).toBeTruthy();
|
|
298
|
+
|
|
299
|
+
const evt = await readFrame(reader);
|
|
300
|
+
expect(evt.type).toBe("new_task");
|
|
301
|
+
expect(evt.task_id).toBe(dispatched.task_id);
|
|
302
|
+
expect(evt.from).toBe("third-party-sender");
|
|
303
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
304
|
+
// Online session was seeded → delivered; offline would report queued.
|
|
305
|
+
expect(evt.status).toBe("delivered");
|
|
306
|
+
expect(evt.priority).toBe("high");
|
|
307
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
308
|
+
expect(evt.scope).toBe("network");
|
|
309
|
+
// The acceptance line: summary must NOT leak the task body.
|
|
310
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
311
|
+
await reader.cancel();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("MCP send_reply → observer gets new_reply summary, reply text NOT in frame", async () => {
|
|
315
|
+
// Drive the REAL registered MCP handler (same interception pattern as
|
|
316
|
+
// ack-create-request.test.ts) with the member's network enforced, and
|
|
317
|
+
// read the observer stream over real HTTP.
|
|
318
|
+
const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
|
|
319
|
+
const reader = res.body!.getReader();
|
|
320
|
+
await readFrame(reader); // connected
|
|
321
|
+
|
|
322
|
+
const tools: Record<string, (args: any) => Promise<any>> = {};
|
|
323
|
+
const mcp = new McpServer({ name: "test", version: "0" }) as any;
|
|
324
|
+
mcp.tool = (name: string, _desc: string, _schema: any, handler: any) => { tools[name] = handler; };
|
|
325
|
+
registerTools(mcp, undefined, memberNetworkId, memberUserId);
|
|
326
|
+
expect(typeof tools["send_reply"]).toBe("function");
|
|
327
|
+
|
|
328
|
+
const secret = `SECRET-REPLY-TEXT-${Date.now()}`;
|
|
329
|
+
const result = await tools["send_reply"]({
|
|
330
|
+
alias: TARGET_ALIAS,
|
|
331
|
+
text: secret,
|
|
332
|
+
status: "replied",
|
|
333
|
+
from_session: "reply-worker",
|
|
334
|
+
});
|
|
335
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
336
|
+
expect(parsed.ok).toBe(true);
|
|
337
|
+
|
|
338
|
+
const evt = await readFrame(reader);
|
|
339
|
+
expect(evt.type).toBe("new_reply");
|
|
340
|
+
expect(evt.from).toBe("reply-worker");
|
|
341
|
+
expect(evt.to).toBe(TARGET_ALIAS);
|
|
342
|
+
expect(evt.status).toBe("replied");
|
|
343
|
+
expect(evt.message_id).toBe(parsed.message_id);
|
|
344
|
+
expect(evt.network_id).toBe(memberNetworkId);
|
|
345
|
+
expect(JSON.stringify(evt)).not.toContain(secret);
|
|
346
|
+
await reader.cancel();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("#462 PUT /api/nodes/:ref/avatar + GET /api/nodes", () => {
|
|
351
|
+
test("set avatar → 200, GET /api/nodes returns it (cross-device persistence)", async () => {
|
|
352
|
+
const avatarUrl = "https://cdn.example.com/avatars/custom-1.png";
|
|
353
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
354
|
+
method: "PUT",
|
|
355
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
356
|
+
body: JSON.stringify({ avatar_url: avatarUrl }),
|
|
357
|
+
});
|
|
358
|
+
expect(put.status).toBe(200);
|
|
359
|
+
const putBody = await put.json() as any;
|
|
360
|
+
expect(putBody.ok).toBe(true);
|
|
361
|
+
expect(putBody.avatar_url).toBe(avatarUrl);
|
|
362
|
+
|
|
363
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
364
|
+
expect(list.status).toBe(200);
|
|
365
|
+
const listBody = await list.json() as any;
|
|
366
|
+
expect(listBody.ok).toBe(true);
|
|
367
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
368
|
+
expect(row).toBeTruthy();
|
|
369
|
+
expect(row.avatar_url).toBe(avatarUrl);
|
|
370
|
+
// #312 discipline still holds — internals must not leak.
|
|
371
|
+
expect("config_snapshot" in row).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("markup-hostile chars are stored NORMALIZED (href), not raw (通信龙 review finding 3)", async () => {
|
|
375
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
376
|
+
method: "PUT",
|
|
377
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
378
|
+
body: JSON.stringify({ avatar_url: 'https://cdn.example.com/a"b.png' }),
|
|
379
|
+
});
|
|
380
|
+
expect(put.status).toBe(200);
|
|
381
|
+
const putBody = await put.json() as any;
|
|
382
|
+
expect(putBody.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
383
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
384
|
+
expect(row.avatar_url).toBe("https://cdn.example.com/a%22b.png");
|
|
385
|
+
expect(row.avatar_url).not.toContain('"');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("clear avatar with null → avatar_url null in list", async () => {
|
|
389
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
390
|
+
method: "PUT",
|
|
391
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
392
|
+
body: JSON.stringify({ avatar_url: null }),
|
|
393
|
+
});
|
|
394
|
+
expect(put.status).toBe(200);
|
|
395
|
+
const list = await fetch(`${BASE}/api/nodes?node_id=${AVATAR_NODE_ID}`, { headers: auth(memberToken) });
|
|
396
|
+
const listBody = await list.json() as any;
|
|
397
|
+
const row = listBody.nodes.find((n: any) => n.node_id === AVATAR_NODE_ID);
|
|
398
|
+
expect(row.avatar_url).toBeNull();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("javascript: URL → 400 invalid_avatar_url, value NOT persisted", async () => {
|
|
402
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
403
|
+
method: "PUT",
|
|
404
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
405
|
+
body: JSON.stringify({ avatar_url: "javascript:alert(1)" }),
|
|
406
|
+
});
|
|
407
|
+
expect(put.status).toBe(400);
|
|
408
|
+
const body = await put.json() as any;
|
|
409
|
+
expect(body.error).toBe("invalid_avatar_url");
|
|
410
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
411
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("data: URL → 400", async () => {
|
|
415
|
+
const put = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
416
|
+
method: "PUT",
|
|
417
|
+
headers: { ...auth(memberToken), "Content-Type": "application/json" },
|
|
418
|
+
body: JSON.stringify({ avatar_url: "data:text/html,<script>alert(1)</script>" }),
|
|
419
|
+
});
|
|
420
|
+
expect(put.status).toBe(400);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("anonymous → 401; non-member → 404/403 (network-scoped lookup)", async () => {
|
|
424
|
+
const anon = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
425
|
+
method: "PUT",
|
|
426
|
+
headers: { "Content-Type": "application/json" },
|
|
427
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
428
|
+
});
|
|
429
|
+
expect(anon.status).toBe(401);
|
|
430
|
+
|
|
431
|
+
// Outsider's network scope can't even see the node → 404 (or 403 if
|
|
432
|
+
// visibility rules change later; both deny the write).
|
|
433
|
+
const outsider = await fetch(`${BASE}/api/nodes/${AVATAR_NODE_ID}/avatar`, {
|
|
434
|
+
method: "PUT",
|
|
435
|
+
headers: { ...auth(outsiderToken), "Content-Type": "application/json" },
|
|
436
|
+
body: JSON.stringify({ avatar_url: "https://example.com/x.png" }),
|
|
437
|
+
});
|
|
438
|
+
expect([403, 404]).toContain(outsider.status);
|
|
439
|
+
const row = db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", AVATAR_NODE_ID);
|
|
440
|
+
expect(row.avatar_url ?? null).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
});
|