@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/cross-tenant-injection.test.ts +110 -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 +1268 -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 +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2128
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +281 -0
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +641 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +5426 -275
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Project folder view — list_node_files / read_node_file ride the rules-file
|
|
2
|
+
// queue + doorbell (ops files_list / file_read).
|
|
3
|
+
//
|
|
4
|
+
// Hub-side contract verified here:
|
|
5
|
+
// 1. report_status { files_capable: true } is sticky and set only by the
|
|
6
|
+
// session's own node token (bound or legacy-unbound, same rule as skills).
|
|
7
|
+
// 2. Both tools reach a session with no nodes row by alias; the pulled
|
|
8
|
+
// request carries the normalized RELATIVE path; results come back through
|
|
9
|
+
// get_rules_file_result.
|
|
10
|
+
// 3. Absolute paths, `..`, `~`, drive letters, backslashes and NUL are refused
|
|
11
|
+
// before any request row exists (the node re-checks; this is the first wall).
|
|
12
|
+
// 4. A node token may not enqueue a files op (it would read another node's
|
|
13
|
+
// disk), and only the token that asked may read a files result.
|
|
14
|
+
// 5. A token bound to node A never pulls or acks node B's files request; an
|
|
15
|
+
// unbound legacy token falls back to its alias exactly as it does for skills.
|
|
16
|
+
// 6. Its own single-flight lane; rules-capable-only sessions are refused.
|
|
17
|
+
// 7. Files acks may carry up to 1 MiB (escaped JSON of a 256 KiB file); rules
|
|
18
|
+
// acks keep their 256 KiB cap.
|
|
19
|
+
//
|
|
20
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/files-transport.db bun test src/node-files-transport.test.ts
|
|
21
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
22
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
23
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
import { registerTools } from "./tools.js";
|
|
27
|
+
|
|
28
|
+
const NET = "net_files_session";
|
|
29
|
+
const OTHER_NET = "net_files_session_other";
|
|
30
|
+
const USER = "u_files_session_owner";
|
|
31
|
+
const SESSION_ALIAS = "files-session-cc";
|
|
32
|
+
const SESSION_RESUME = "cc-files-session-1";
|
|
33
|
+
const PEER_ALIAS = "files-session-peer";
|
|
34
|
+
const NODE_A = "files-node-a";
|
|
35
|
+
const NODE_B = "files-node-b";
|
|
36
|
+
const NODE_A_ID = "node_files_a";
|
|
37
|
+
const NODE_B_ID = "node_files_b";
|
|
38
|
+
const LEGACY_ALIAS = "files-node-legacy";
|
|
39
|
+
const LEGACY_ID = "node_files_legacy";
|
|
40
|
+
const T_SESSION = "tok_files_session_cc";
|
|
41
|
+
const T_PEER = "tok_files_session_peer";
|
|
42
|
+
const T_OTHER_NET_SAME_ALIAS = "tok_files_session_other_net";
|
|
43
|
+
const T_A = "tok_files_node_a";
|
|
44
|
+
const T_B = "tok_files_node_b";
|
|
45
|
+
const T_LEGACY = "tok_files_node_legacy";
|
|
46
|
+
|
|
47
|
+
function cleanup() {
|
|
48
|
+
for (const net of [NET, OTHER_NET]) {
|
|
49
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
50
|
+
try { db.run("DELETE FROM rename_txn WHERE network_id = ?1", [net]); } catch {}
|
|
51
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
|
|
52
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
53
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
54
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
55
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
56
|
+
}
|
|
57
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function seedWorld() {
|
|
61
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
|
|
62
|
+
for (const net of [NET, OTHER_NET]) {
|
|
63
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
64
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
65
|
+
}
|
|
66
|
+
const tok = (id: string, net: string, alias: string, bound: string | null = null) => db.run(
|
|
67
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
68
|
+
[id, USER, net, `node:${alias}`, `hash_${id}`, bound],
|
|
69
|
+
);
|
|
70
|
+
const node = (id: string, alias: string) => db.run(
|
|
71
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
72
|
+
[id, alias, alias, NET],
|
|
73
|
+
);
|
|
74
|
+
node(NODE_A_ID, NODE_A);
|
|
75
|
+
node(NODE_B_ID, NODE_B);
|
|
76
|
+
node(LEGACY_ID, LEGACY_ALIAS);
|
|
77
|
+
tok(T_SESSION, NET, SESSION_ALIAS);
|
|
78
|
+
tok(T_PEER, NET, PEER_ALIAS);
|
|
79
|
+
tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
|
|
80
|
+
tok(T_A, NET, NODE_A, NODE_A_ID);
|
|
81
|
+
tok(T_B, NET, NODE_B, NODE_B_ID);
|
|
82
|
+
tok(T_LEGACY, NET, LEGACY_ALIAS, null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
86
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user" };
|
|
87
|
+
const asUserOtherLogin: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user_other_login" };
|
|
88
|
+
const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
|
|
89
|
+
const asPeer: Identity = { net: NET, alias: PEER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
|
|
90
|
+
const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
|
|
91
|
+
const asA: Identity = { net: NET, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
|
|
92
|
+
const asB: Identity = { net: NET, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
|
|
93
|
+
const asLegacy: Identity = { net: NET, alias: LEGACY_ALIAS, isNetworkToken: true, tokenId: T_LEGACY };
|
|
94
|
+
|
|
95
|
+
async function connect(id: Identity) {
|
|
96
|
+
const server = new McpServer({ name: "files-session-test", version: "1" });
|
|
97
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
98
|
+
const client = new Client({ name: "files-session-client", version: "1" });
|
|
99
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
100
|
+
await server.connect(st);
|
|
101
|
+
await client.connect(ct);
|
|
102
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
103
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
104
|
+
const first = r.content?.[0];
|
|
105
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
106
|
+
try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
|
|
107
|
+
};
|
|
108
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
109
|
+
return { call, close };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const capable = (alias: string, net = NET) =>
|
|
113
|
+
db.get<{ c: number }>("SELECT files_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
|
|
114
|
+
const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
|
|
115
|
+
|
|
116
|
+
async function report(id: Identity, alias: string, resume: string, flags: Record<string, true>) {
|
|
117
|
+
const c = await connect(id);
|
|
118
|
+
try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...flags }); }
|
|
119
|
+
finally { await c.close(); }
|
|
120
|
+
}
|
|
121
|
+
const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true } as const;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
124
|
+
afterAll(cleanup);
|
|
125
|
+
|
|
126
|
+
describe("files_capable is set only by the session's own node token", () => {
|
|
127
|
+
test("own token sets it; a later report without it does not clear it; a peer cannot set it", async () => {
|
|
128
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
129
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
130
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, {});
|
|
131
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
132
|
+
const r = await report(asPeer, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
133
|
+
expect(r).toMatchObject({ ok: false });
|
|
134
|
+
// A user login cannot set it either.
|
|
135
|
+
await report(asUser, PEER_ALIAS, "cc-files-user-report", ALL_FLAGS);
|
|
136
|
+
expect(capable(PEER_ALIAS) ?? 0).toBe(0);
|
|
137
|
+
}, 20_000);
|
|
138
|
+
|
|
139
|
+
test("a stale process whose report is rewritten to a renamed alias does not grant that alias the flag", async () => {
|
|
140
|
+
const NEW_ALIAS = "files-session-renamed";
|
|
141
|
+
db.run(
|
|
142
|
+
`INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status, committed_at) VALUES ('txn_files_rename', ?1, ?2, ?3, 'committed', datetime('now'))`,
|
|
143
|
+
[NET, PEER_ALIAS, NEW_ALIAS],
|
|
144
|
+
);
|
|
145
|
+
// Token still carries the old alias; the hub rewrites the report to NEW_ALIAS.
|
|
146
|
+
const r = await report(asPeer, PEER_ALIAS, "cc-files-renamed", ALL_FLAGS);
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE alias = ?1 AND network_id = ?2", NEW_ALIAS, NET)!.n).toBe(1);
|
|
149
|
+
expect(capable(NEW_ALIAS)).toBe(0);
|
|
150
|
+
}, 20_000);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("list / read by alias reach a session with no nodes row", () => {
|
|
154
|
+
test("list: pulled request carries the normalized relative path; result comes back", async () => {
|
|
155
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
156
|
+
const u = await connect(asUser);
|
|
157
|
+
const s = await connect(asSession);
|
|
158
|
+
try {
|
|
159
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS, path: "./src//lib/" });
|
|
160
|
+
expect(enq).toMatchObject({ ok: true, op: "files_list" });
|
|
161
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
162
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "files_list", content: "src/lib" });
|
|
163
|
+
const payload = JSON.stringify({ path: "src/lib", entries: [{ name: "a.ts", type: "file", size: 3, mtime: 1 }], truncated: false, total: 1 });
|
|
164
|
+
expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "files", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
|
|
165
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, op: "files_list", status: "done", content: payload });
|
|
166
|
+
} finally { await u.close(); await s.close(); }
|
|
167
|
+
}, 20_000);
|
|
168
|
+
|
|
169
|
+
test("list with no path targets the root (empty content)", async () => {
|
|
170
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
171
|
+
const u = await connect(asUser);
|
|
172
|
+
const s = await connect(asSession);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
175
|
+
expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "files_list", content: "" });
|
|
176
|
+
} finally { await u.close(); await s.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("read round trip", async () => {
|
|
180
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
181
|
+
const u = await connect(asUser);
|
|
182
|
+
const s = await connect(asSession);
|
|
183
|
+
try {
|
|
184
|
+
const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "docs/README.md" });
|
|
185
|
+
expect(enq).toMatchObject({ ok: true, op: "file_read" });
|
|
186
|
+
expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "docs/README.md" });
|
|
187
|
+
const payload = JSON.stringify({ path: "docs/README.md", name: "README.md", kind: "text", size: 6, content: "# hi\n" });
|
|
188
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "README.md", exists: true, content: payload });
|
|
189
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
|
|
190
|
+
} finally { await u.close(); await s.close(); }
|
|
191
|
+
}, 20_000);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("path validation happens before any row is written", () => {
|
|
195
|
+
test("absolute, .., ~, drive letters, backslashes, NUL, and an empty read path are refused", async () => {
|
|
196
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
197
|
+
const u = await connect(asUser);
|
|
198
|
+
try {
|
|
199
|
+
const bad = ["/etc/passwd", "../outside", "a/../../b", "src/..", "~/.ssh/id_rsa", "C:/Windows", "a\\b", "..\\x", "a\u0000b", "x".repeat(1025)];
|
|
200
|
+
for (const path of bad) {
|
|
201
|
+
// Over-long paths are stopped by the zod schema (an MCP input error); the rest by invalid_path.
|
|
202
|
+
const r = await u.call("read_node_file", { alias: SESSION_ALIAS, path });
|
|
203
|
+
expect(r.ok).not.toBe(true);
|
|
204
|
+
if (path.length <= 1024) expect(r).toMatchObject({ ok: false, error: "invalid_path" });
|
|
205
|
+
const l = await u.call("list_node_files", { alias: SESSION_ALIAS, path });
|
|
206
|
+
expect(l.ok).not.toBe(true);
|
|
207
|
+
}
|
|
208
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "/" })).toMatchObject({ ok: false, error: "invalid_path" });
|
|
209
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "./" })).toMatchObject({ ok: false, error: "invalid_path" });
|
|
210
|
+
expect(rowCount()).toBe(0);
|
|
211
|
+
} finally { await u.close(); }
|
|
212
|
+
}, 20_000);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("who may ask, and who may read the answer", () => {
|
|
216
|
+
test("a node token cannot enqueue a files op on another node (or itself)", async () => {
|
|
217
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
218
|
+
const a = await connect(asA);
|
|
219
|
+
try {
|
|
220
|
+
expect(await a.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
221
|
+
expect(await a.call("read_node_file", { node_id: NODE_B_ID, path: ".env" })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
222
|
+
expect(await a.call("list_node_files", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
223
|
+
expect(rowCount()).toBe(0);
|
|
224
|
+
} finally { await a.close(); }
|
|
225
|
+
}, 20_000);
|
|
226
|
+
|
|
227
|
+
test("only the token that asked can read a files result; node tokens never can", async () => {
|
|
228
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
229
|
+
const u = await connect(asUser);
|
|
230
|
+
const other = await connect(asUserOtherLogin);
|
|
231
|
+
const s = await connect(asSession);
|
|
232
|
+
const a = await connect(asA);
|
|
233
|
+
try {
|
|
234
|
+
const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "notes.txt" });
|
|
235
|
+
await s.call("get_rules_file_request", {});
|
|
236
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: JSON.stringify({ path: "notes.txt", kind: "text", content: "x" }) });
|
|
237
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "done" });
|
|
238
|
+
expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
239
|
+
expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
240
|
+
// The serving session itself cannot read the stored result back through the client tool either.
|
|
241
|
+
expect(await s.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
242
|
+
} finally { await u.close(); await other.close(); await s.close(); await a.close(); }
|
|
243
|
+
}, 20_000);
|
|
244
|
+
|
|
245
|
+
test("rules/skills results stay readable by any writer of the network (unchanged)", async () => {
|
|
246
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
247
|
+
const u = await connect(asUser);
|
|
248
|
+
const other = await connect(asUserOtherLogin);
|
|
249
|
+
try {
|
|
250
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
251
|
+
expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending" });
|
|
252
|
+
} finally { await u.close(); await other.close(); }
|
|
253
|
+
}, 20_000);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("a token bound to node A cannot touch node B's files request", () => {
|
|
257
|
+
test("B's request: A pulls nothing, A's ack is ignored, B pulls and acks it", async () => {
|
|
258
|
+
const u = await connect(asUser);
|
|
259
|
+
const a = await connect(asA);
|
|
260
|
+
const b = await connect(asB);
|
|
261
|
+
try {
|
|
262
|
+
const enq = await u.call("read_node_file", { node_id: NODE_B_ID, path: "secret-plan.md" });
|
|
263
|
+
expect(enq).toMatchObject({ ok: true });
|
|
264
|
+
expect((await a.call("get_rules_file_request", {})).request).toBeNull();
|
|
265
|
+
expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
266
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
267
|
+
expect((await b.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "secret-plan.md" });
|
|
268
|
+
expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
269
|
+
} finally { await u.close(); await a.close(); await b.close(); }
|
|
270
|
+
}, 20_000);
|
|
271
|
+
|
|
272
|
+
test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
|
|
273
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
274
|
+
const u = await connect(asUser);
|
|
275
|
+
const other = await connect(asOtherNetSameAlias);
|
|
276
|
+
const peer = await connect(asPeer);
|
|
277
|
+
try {
|
|
278
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
279
|
+
expect((await other.call("get_rules_file_request", {})).request).toBeNull();
|
|
280
|
+
expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
|
|
281
|
+
expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
|
|
282
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
283
|
+
} finally { await u.close(); await other.close(); await peer.close(); }
|
|
284
|
+
}, 20_000);
|
|
285
|
+
|
|
286
|
+
test("an unbound legacy token gets what skills gives it: alias fallback pull + ack, and it can set its own flag", async () => {
|
|
287
|
+
const u = await connect(asUser);
|
|
288
|
+
const legacy = await connect(asLegacy);
|
|
289
|
+
try {
|
|
290
|
+
await report(asLegacy, LEGACY_ALIAS, `sdk-${LEGACY_ID}`, ALL_FLAGS);
|
|
291
|
+
expect(capable(LEGACY_ALIAS)).toBe(1);
|
|
292
|
+
const skills = await u.call("list_node_skills", { node_id: LEGACY_ID });
|
|
293
|
+
const files = await u.call("list_node_files", { node_id: LEGACY_ID, path: "src" });
|
|
294
|
+
expect(skills).toMatchObject({ ok: true });
|
|
295
|
+
expect(files).toMatchObject({ ok: true });
|
|
296
|
+
const first = (await legacy.call("get_rules_file_request", {})).request;
|
|
297
|
+
const second = (await legacy.call("get_rules_file_request", {})).request;
|
|
298
|
+
expect([first.request_id, second.request_id].sort()).toEqual([skills.request_id, files.request_id].sort());
|
|
299
|
+
expect(await legacy.call("ack_rules_file_request", { request_id: files.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
300
|
+
expect(await legacy.call("ack_rules_file_request", { request_id: skills.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
301
|
+
} finally { await u.close(); await legacy.close(); }
|
|
302
|
+
}, 20_000);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("lanes, capability gate and ack size", () => {
|
|
306
|
+
test("files has its own single-flight lane", async () => {
|
|
307
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
308
|
+
const u = await connect(asUser);
|
|
309
|
+
try {
|
|
310
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
311
|
+
expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
312
|
+
const list = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
313
|
+
expect(list).toMatchObject({ ok: true });
|
|
314
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "a.txt" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
|
|
315
|
+
} finally { await u.close(); }
|
|
316
|
+
}, 20_000);
|
|
317
|
+
|
|
318
|
+
test("rules+skills capable but not files capable → files_target_not_found, no row", async () => {
|
|
319
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, { rules_file_capable: true, skills_capable: true });
|
|
320
|
+
const u = await connect(asUser);
|
|
321
|
+
try {
|
|
322
|
+
expect(await u.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "files_target_not_found" });
|
|
323
|
+
expect(rowCount()).toBe(0);
|
|
324
|
+
} finally { await u.close(); }
|
|
325
|
+
}, 20_000);
|
|
326
|
+
|
|
327
|
+
test("a files ack may carry up to 1 MiB; a rules ack keeps its 256 KiB cap", async () => {
|
|
328
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
329
|
+
const u = await connect(asUser);
|
|
330
|
+
const s = await connect(asSession);
|
|
331
|
+
try {
|
|
332
|
+
const big = JSON.stringify({ path: "big.txt", kind: "text", content: "\n".repeat(256 * 1024) });
|
|
333
|
+
expect(big.length).toBeGreaterThan(256 * 1024);
|
|
334
|
+
const f = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "big.txt" });
|
|
335
|
+
const r = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
|
|
336
|
+
await s.call("get_rules_file_request", {});
|
|
337
|
+
await s.call("get_rules_file_request", {});
|
|
338
|
+
expect(await s.call("ack_rules_file_request", { request_id: f.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
|
|
339
|
+
expect(await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(256 * 1024 + 1) })).toMatchObject({ ok: false, error: "content_too_large" });
|
|
340
|
+
const tooBig = await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(1024 * 1024 + 1) });
|
|
341
|
+
expect(tooBig.ok).not.toBe(true);
|
|
342
|
+
} finally { await u.close(); await s.close(); }
|
|
343
|
+
}, 20_000);
|
|
344
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// #460(#448 子项)—— 降级节点拒收新任务。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. 新鲜健康报告说某层坏了(app_server.ok=false / tui.ok=false / model_auth revoked|expired)
|
|
5
|
+
// → REST /api/task 409、MCP send_task / retry_task / reassign_task 返回 node_degraded,带层、原因、修法;
|
|
6
|
+
// 定时任务的这一次 run 记 failed + error_code=node_degraded,不建任务行。
|
|
7
|
+
// 2. 不知道 = 放行:没有报告(老 agent-node)、报告过期(> TTL)、各层都 ok、某层没报 —— 与改动前逐字相同的成功路径。
|
|
8
|
+
// 3. 逃生口 force=true 只对用户令牌生效;节点令牌带 force 照样被拒(不让 agent 绕过对另一个 agent 的健康判断)。
|
|
9
|
+
// 4. 老客户端请求形状(不带 force)在健康 / 未知节点上完全照旧 —— 兼容回放。
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-health-dispatch-http.test.ts
|
|
12
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
19
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, recordNodeHealth } from "./node-health-store.js";
|
|
24
|
+
import { degradedLayers } from "./node-health-guard.js";
|
|
25
|
+
import { runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
26
|
+
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-health-dispatch-"));
|
|
28
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
29
|
+
if (!activeDbPath) throw new Error("node-health-dispatch requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
30
|
+
|
|
31
|
+
let server: any;
|
|
32
|
+
let base = "";
|
|
33
|
+
let ownerToken = "", ownerId = "", networkId = "";
|
|
34
|
+
let peerNodeToken = "", peerNodeTokenId = "";
|
|
35
|
+
const stamp = Date.now();
|
|
36
|
+
const TARGET = `hd-target-${stamp}`;
|
|
37
|
+
const TARGET_NODE = `n_hd_target_${stamp}`;
|
|
38
|
+
const PEER = `hd-peer-${stamp}`;
|
|
39
|
+
const PEER_NODE = `n_hd_peer_${stamp}`;
|
|
40
|
+
let seq = 0;
|
|
41
|
+
const text = (what: string) => `${what} #${++seq}`; // distinct content: the 5-minute dedup must not interfere
|
|
42
|
+
|
|
43
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
44
|
+
const res = await fetch(`${base}${path}`, {
|
|
45
|
+
...init,
|
|
46
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
47
|
+
});
|
|
48
|
+
return { status: res.status, body: await res.json() as any };
|
|
49
|
+
}
|
|
50
|
+
const postTask = (token: string, extra: Record<string, unknown> = {}) =>
|
|
51
|
+
api(token, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("rest"), network_id: networkId, ...extra }) });
|
|
52
|
+
|
|
53
|
+
async function mcpAs(identity: { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string }) {
|
|
54
|
+
const s = new McpServer({ name: "health-dispatch", version: "1" });
|
|
55
|
+
registerTools(s, undefined, identity.net, identity.user, identity.alias, identity.isNetworkToken, identity.tokenId);
|
|
56
|
+
const client = new Client({ name: "health-dispatch-client", version: "1" });
|
|
57
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
58
|
+
await s.connect(st);
|
|
59
|
+
await client.connect(ct);
|
|
60
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
61
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
62
|
+
const raw = r.content?.[0]?.text ?? "null";
|
|
63
|
+
try { return JSON.parse(raw); } catch { return { raw }; }
|
|
64
|
+
};
|
|
65
|
+
return { call, close: async () => { await client.close(); await s.close(); } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DOWN_APP = { bridge: "ok" as const, app_server: { ok: false, rtt_ms: null, last_error: "connect ECONNREFUSED 127.0.0.1:4500" }, model_auth: "ok" as const };
|
|
69
|
+
const HEALTHY = { bridge: "ok" as const, app_server: { ok: true, rtt_ms: 3, last_error: null }, tui: { ok: true, reason: "running" }, model_auth: "ok" as const };
|
|
70
|
+
const setHealth = (h: any, at?: number) => recordNodeHealth(networkId, TARGET, h, at);
|
|
71
|
+
const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, networkId)!.n;
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
75
|
+
process.env.HOST = "127.0.0.1";
|
|
76
|
+
const owner = register(`hd_owner_${stamp}`, "HealthDispatchOwner123!", undefined, "seed");
|
|
77
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; networkId = owner.network_id!;
|
|
78
|
+
for (const [nodeId, alias] of [[TARGET_NODE, TARGET], [PEER_NODE, PEER]]) {
|
|
79
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server', ?3, ?4)`, [nodeId, alias, networkId, ownerId]);
|
|
80
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)`, [`r_${nodeId}`, alias, nodeId, networkId]);
|
|
81
|
+
}
|
|
82
|
+
const minted = createNetworkTokenForNode(ownerId, networkId, PEER, PEER_NODE);
|
|
83
|
+
peerNodeToken = minted.token!; peerNodeTokenId = minted.token_id!;
|
|
84
|
+
const mod: any = await import("./server.js");
|
|
85
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
86
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
87
|
+
}, 30_000);
|
|
88
|
+
afterAll(() => { server?.stop?.(true); clearNodeHealthStore(); });
|
|
89
|
+
beforeEach(() => clearNodeHealthStore());
|
|
90
|
+
|
|
91
|
+
describe("#460 which layers count as degraded", () => {
|
|
92
|
+
test("each failing layer is named; ok / missing layers are not", () => {
|
|
93
|
+
expect(degradedLayers(null)).toEqual([]);
|
|
94
|
+
expect(degradedLayers(HEALTHY)).toEqual([]);
|
|
95
|
+
expect(degradedLayers({ bridge: "ok", model_auth: "unknown" })).toEqual([]);
|
|
96
|
+
expect(degradedLayers(DOWN_APP).map(l => [l.layer, l.label])).toEqual([["app_server", "App Server 断开"]]);
|
|
97
|
+
expect(degradedLayers({ tui: { ok: false, reason: "sleep-placeholder" } }).map(l => [l.layer, l.reason, l.label])).toEqual([["tui", "sleep-placeholder", "TUI 被占位(sleep)"]]);
|
|
98
|
+
expect(degradedLayers({ model_auth: "revoked" }).map(l => l.label)).toEqual(["需要重新登录"]);
|
|
99
|
+
expect(degradedLayers({ model_auth: "expired" }).map(l => l.label)).toEqual(["登录已过期"]);
|
|
100
|
+
// 修法只指向本节点重新登录,不教人拷别人的凭据
|
|
101
|
+
expect(degradedLayers({ model_auth: "revoked" })[0].hint).toContain("不要拷别的节点的 auth.json");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("#460 REST /api/task", () => {
|
|
106
|
+
test("unknown health (old agent-node) → dispatched exactly as before", async () => {
|
|
107
|
+
const before = taskCount();
|
|
108
|
+
const r = await postTask(ownerToken);
|
|
109
|
+
expect(r.status).toBe(200);
|
|
110
|
+
expect(r.body.ok).toBe(true);
|
|
111
|
+
expect(taskCount()).toBe(before + 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("healthy report → dispatched", async () => {
|
|
115
|
+
setHealth(HEALTHY);
|
|
116
|
+
expect((await postTask(ownerToken)).status).toBe(200);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("App Server down → 409 node_degraded with layer, reason and hint; no task row", async () => {
|
|
120
|
+
setHealth(DOWN_APP);
|
|
121
|
+
const before = taskCount();
|
|
122
|
+
const r = await postTask(ownerToken);
|
|
123
|
+
expect(r.status).toBe(409);
|
|
124
|
+
expect(r.body.ok).toBe(false);
|
|
125
|
+
expect(r.body.error).toBe("node_degraded");
|
|
126
|
+
expect(r.body.alias).toBe(TARGET);
|
|
127
|
+
expect(r.body.layers.map((l: any) => l.layer)).toEqual(["app_server"]);
|
|
128
|
+
expect(r.body.layers[0].reason).toContain("ECONNREFUSED");
|
|
129
|
+
expect(r.body.message).toContain("App Server 断开");
|
|
130
|
+
expect(r.body.hint).toContain("force=true");
|
|
131
|
+
expect(r.body.force_allowed).toBe(true);
|
|
132
|
+
expect(typeof r.body.health_observed_ms_ago).toBe("number");
|
|
133
|
+
expect(taskCount()).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("TUI gone and login revoked are refused too, all layers listed", async () => {
|
|
137
|
+
setHealth({ bridge: "ok", app_server: { ok: true, rtt_ms: 2, last_error: null }, tui: { ok: false, reason: "session-missing" }, model_auth: "revoked" });
|
|
138
|
+
const r = await postTask(ownerToken);
|
|
139
|
+
expect(r.status).toBe(409);
|
|
140
|
+
expect(r.body.layers.map((l: any) => l.layer)).toEqual(["tui", "model_auth"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("expired login alone is refused", async () => {
|
|
144
|
+
setHealth({ bridge: "ok", model_auth: "expired" });
|
|
145
|
+
expect((await postTask(ownerToken)).body.error).toBe("node_degraded");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("stale report (older than the TTL) = unknown → dispatched", async () => {
|
|
149
|
+
setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
|
|
150
|
+
expect((await postTask(ownerToken)).status).toBe(200);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("owner escape hatch: force=true with a user token dispatches", async () => {
|
|
154
|
+
setHealth(DOWN_APP);
|
|
155
|
+
const before = taskCount();
|
|
156
|
+
const r = await postTask(ownerToken, { force: true });
|
|
157
|
+
expect(r.status).toBe(200);
|
|
158
|
+
expect(taskCount()).toBe(before + 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a node token cannot force past another node's health", async () => {
|
|
162
|
+
setHealth(DOWN_APP);
|
|
163
|
+
const r = await api(peerNodeToken, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("node-force"), force: true }) });
|
|
164
|
+
expect(r.status).toBe(409);
|
|
165
|
+
expect(r.body.error).toBe("node_degraded");
|
|
166
|
+
expect(r.body.force_allowed).toBe(false);
|
|
167
|
+
expect(r.body.hint).not.toContain("force=true");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("old-client request shapes are untouched on a healthy node (compat replay)", async () => {
|
|
171
|
+
setHealth(HEALTHY);
|
|
172
|
+
// the app's send (meta + client_request_id), a plain anet send, and a parent-linked dispatch
|
|
173
|
+
const shapes = [
|
|
174
|
+
{ alias: TARGET, task: text("app"), network_id: networkId, priority: "normal", meta: { client_request_id: `cr_${stamp}_${seq}` } },
|
|
175
|
+
{ alias: TARGET, task: text("anet"), network_id: networkId },
|
|
176
|
+
{ alias: TARGET, task: text("ttl"), network_id: networkId, priority: "high", ttl_seconds: 600 },
|
|
177
|
+
];
|
|
178
|
+
for (const shape of shapes) {
|
|
179
|
+
const r = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify(shape) });
|
|
180
|
+
expect(r.status).toBe(200);
|
|
181
|
+
expect(r.body.ok).toBe(true);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("#460 MCP send_task / retry_task / reassign_task", () => {
|
|
187
|
+
const asOwner = () => ({ net: null, user: ownerId, alias: `hd_owner_${stamp}`, isNetworkToken: false, tokenId: "tok_hd_owner_login" });
|
|
188
|
+
const asPeerNode = () => ({ net: networkId, user: ownerId, alias: PEER, isNetworkToken: true, tokenId: peerNodeTokenId });
|
|
189
|
+
|
|
190
|
+
test("send_task from an agent to a degraded node → node_degraded (no queueing); force ignored for node tokens", async () => {
|
|
191
|
+
setHealth(DOWN_APP);
|
|
192
|
+
const peer = await mcpAs(asPeerNode());
|
|
193
|
+
try {
|
|
194
|
+
const before = taskCount();
|
|
195
|
+
const r = await peer.call("send_task", { alias: TARGET, task: text("mcp"), force: true });
|
|
196
|
+
expect(r.ok).toBe(false);
|
|
197
|
+
expect(r.error).toBe("node_degraded");
|
|
198
|
+
expect(r.force_allowed).toBe(false);
|
|
199
|
+
expect(taskCount()).toBe(before);
|
|
200
|
+
} finally { await peer.close(); }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("send_task: unknown health → ok; owner force → ok", async () => {
|
|
204
|
+
const owner = await mcpAs(asOwner());
|
|
205
|
+
try {
|
|
206
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-unknown"), network_id: networkId })).ok).toBe(true);
|
|
207
|
+
setHealth({ bridge: "ok", model_auth: "revoked" });
|
|
208
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-refused"), network_id: networkId })).error).toBe("node_degraded");
|
|
209
|
+
expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-forced"), network_id: networkId, force: true })).ok).toBe(true);
|
|
210
|
+
} finally { await owner.close(); }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("retry_task and reassign_task onto a degraded node are refused", async () => {
|
|
214
|
+
const owner = await mcpAs(asOwner());
|
|
215
|
+
try {
|
|
216
|
+
const sent = await owner.call("send_task", { alias: PEER, task: text("to-peer"), network_id: networkId });
|
|
217
|
+
expect(sent.ok).toBe(true);
|
|
218
|
+
setHealth(DOWN_APP);
|
|
219
|
+
const re = await owner.call("reassign_task", { task_id: sent.task_id ?? sent.message_id, new_alias: TARGET, network_id: networkId });
|
|
220
|
+
expect(re.error).toBe("node_degraded");
|
|
221
|
+
const failed = await owner.call("send_task", { alias: TARGET, task: text("to-fail"), network_id: networkId, force: true });
|
|
222
|
+
const failedId = failed.task_id ?? failed.message_id;
|
|
223
|
+
db.run("UPDATE tasks SET status = 'failed' WHERE task_id = ?1", [failedId]);
|
|
224
|
+
const retry = await owner.call("retry_task", { task_id: failedId, network_id: networkId });
|
|
225
|
+
expect(retry.error).toBe("node_degraded");
|
|
226
|
+
clearNodeHealthStore();
|
|
227
|
+
expect((await owner.call("retry_task", { task_id: failedId, network_id: networkId })).ok).toBe(true);
|
|
228
|
+
} finally { await owner.close(); }
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("#460 scheduled tasks", () => {
|
|
233
|
+
test("a due run on a degraded node fails with node_degraded and creates no task", async () => {
|
|
234
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
235
|
+
method: "POST",
|
|
236
|
+
body: JSON.stringify({ network_id: networkId, name: `hd-schedule-${stamp}`, target_node_id: TARGET_NODE, task: text("scheduled"), timezone: "UTC", schedule: { type: "interval", every_seconds: 60 } }),
|
|
237
|
+
});
|
|
238
|
+
expect(created.status).toBe(201);
|
|
239
|
+
const scheduleId = created.body.schedule.schedule_id;
|
|
240
|
+
setHealth({ bridge: "ok", tui: { ok: false, reason: "pane-dead" }, model_auth: "ok" });
|
|
241
|
+
const before = taskCount();
|
|
242
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
|
|
243
|
+
runDueScheduledTasks();
|
|
244
|
+
const run = db.get<{ status: string; error_code: string | null; error_message: string | null; task_id: string | null }>(
|
|
245
|
+
"SELECT status, error_code, error_message, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId);
|
|
246
|
+
expect(run?.status).toBe("failed");
|
|
247
|
+
expect(run?.error_code).toBe("node_degraded");
|
|
248
|
+
expect(run?.error_message).toContain("TUI 已退出");
|
|
249
|
+
expect(run?.task_id ?? null).toBeNull();
|
|
250
|
+
expect(taskCount()).toBe(before);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("#460 /api/status carries `degraded` only for degraded nodes", () => {
|
|
255
|
+
const row = async (light: boolean) => {
|
|
256
|
+
const r = await api(ownerToken, `/api/status?network_id=${encodeURIComponent(networkId)}${light ? "&light=1" : ""}`);
|
|
257
|
+
expect(r.status).toBe(200);
|
|
258
|
+
return r.body.sessions.find((x: any) => x.alias === TARGET);
|
|
259
|
+
};
|
|
260
|
+
test("unknown / healthy → no `degraded` key at all (light response bytes unchanged)", async () => {
|
|
261
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
262
|
+
setHealth(HEALTHY);
|
|
263
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
264
|
+
expect("degraded" in (await row(false))).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
test("degraded → light and full rows list the failing layers with the app's labels", async () => {
|
|
267
|
+
setHealth({ bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "refused" }, model_auth: "revoked" });
|
|
268
|
+
const want = [
|
|
269
|
+
{ layer: "app_server", label: "App Server 断开", reason: "refused" },
|
|
270
|
+
{ layer: "model_auth", label: "需要重新登录", reason: "revoked" },
|
|
271
|
+
];
|
|
272
|
+
expect((await row(true)).degraded).toEqual(want);
|
|
273
|
+
expect((await row(false)).degraded).toEqual(want);
|
|
274
|
+
});
|
|
275
|
+
test("stale → no `degraded`", async () => {
|
|
276
|
+
setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
|
|
277
|
+
expect("degraded" in (await row(true))).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|