@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
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-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- 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 +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- 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/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 +984 -34
- 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 -2172
- 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 +518 -0
- package/src/requirement-due-reminders.ts +369 -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.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/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -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 +650 -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/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 +4955 -300
- 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,269 @@
|
|
|
1
|
+
// #507 — two live copies of one node (directory copied verbatim: same node token,
|
|
2
|
+
// same alias) used to hold two SSE subscriptions on `${net}:${alias}`. Every new
|
|
3
|
+
// task was pushed to BOTH (each copy ran it), and either copy's shutdown
|
|
4
|
+
// report_status(offline) took the node offline under the other → alias_offline.
|
|
5
|
+
//
|
|
6
|
+
// Pinned here, through the real HTTP entrances (GET /events/:alias with the node
|
|
7
|
+
// token, POST /mcp report_status, POST /api/task, GET /api/stats/sse):
|
|
8
|
+
// 1. newest connection wins: the older node stream gets node_connection_superseded
|
|
9
|
+
// (reason superseded_by_new_connection) and is closed; /api/stats/sse shows 1.
|
|
10
|
+
// 2. a new task reaches exactly ONE node subscriber; a dashboard monitor on the
|
|
11
|
+
// same channel is untouched and still sees it.
|
|
12
|
+
// 3. node_identity_conflict reaches the network observer stream and
|
|
13
|
+
// /api/stats/sse.identity_conflicts, with both remotes and no token.
|
|
14
|
+
// 4. offline only when the last node connection leaves; tasks in between are
|
|
15
|
+
// delivered, not alias_offline.
|
|
16
|
+
// 5. same instance id = a reconnect (replaced_by_reconnect, no conflict);
|
|
17
|
+
// different ids = instance_match "different"; another node's token watching
|
|
18
|
+
// this alias never supersedes it; a plain single-node stop is offline at once.
|
|
19
|
+
//
|
|
20
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-identity-conflict-http.test.ts
|
|
21
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
27
|
+
import { db } from "./db.js";
|
|
28
|
+
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-node-conflict-"));
|
|
30
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
31
|
+
if (!activeDbPath) throw new Error("node-identity-conflict requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
32
|
+
|
|
33
|
+
const stamp = Date.now();
|
|
34
|
+
let server: any;
|
|
35
|
+
let base = "";
|
|
36
|
+
let ownerToken = "", ownerId = "", NET = "";
|
|
37
|
+
const nodes: Record<string, { alias: string; nodeId: string; token: string }> = {};
|
|
38
|
+
|
|
39
|
+
type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
|
|
40
|
+
|
|
41
|
+
function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
|
|
42
|
+
const ctrl = new AbortController();
|
|
43
|
+
const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
|
|
44
|
+
let markReady!: () => void;
|
|
45
|
+
sub.ready = new Promise<void>((r) => { markReady = r; });
|
|
46
|
+
(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers }, signal: ctrl.signal });
|
|
49
|
+
if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const dec = new TextDecoder();
|
|
52
|
+
let buf = "";
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done) break;
|
|
56
|
+
buf += dec.decode(value, { stream: true });
|
|
57
|
+
const lines = buf.split("\n"); buf = lines.pop() || "";
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.startsWith("data: ")) continue;
|
|
60
|
+
const ev = JSON.parse(line.slice(6));
|
|
61
|
+
sub.events.push(ev);
|
|
62
|
+
if (ev.type === "connected") markReady();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch { /* aborted */ }
|
|
66
|
+
sub.done = true;
|
|
67
|
+
markReady();
|
|
68
|
+
})();
|
|
69
|
+
return sub;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function until(cond: () => boolean, what: string, ms = 3000) {
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
while (!cond()) {
|
|
75
|
+
if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
|
|
76
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const settle = () => new Promise((r) => setTimeout(r, 150));
|
|
80
|
+
|
|
81
|
+
async function api(token: string, method: string, path: string, body?: unknown) {
|
|
82
|
+
const res = await fetch(`${base}${path}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
86
|
+
});
|
|
87
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
91
|
+
const res = await fetch(`${base}/mcp`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
94
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
95
|
+
});
|
|
96
|
+
const raw = await res.text();
|
|
97
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
98
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
99
|
+
const text = payload.result.content[0].text;
|
|
100
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reportStatus = (n: { alias: string; nodeId: string; token: string }, status: string) =>
|
|
105
|
+
tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status, node_id: n.nodeId, network_id: NET });
|
|
106
|
+
const storedStatus = (alias: string) =>
|
|
107
|
+
db.get<{ status: string }>("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET)?.status;
|
|
108
|
+
let seq = 0;
|
|
109
|
+
const sendTask = (alias: string) => api(ownerToken, "POST", "/api/task", { alias, task: `conflict task ${stamp} #${++seq}`, network_id: NET });
|
|
110
|
+
const nodeStream = (n: { alias: string; token: string }, headers: Record<string, string> = {}) =>
|
|
111
|
+
subscribe(`/events/${encodeURIComponent(n.alias)}`, n.token, headers);
|
|
112
|
+
const newTasks = (s: Sub) => s.events.filter((e) => e.type === "new_task");
|
|
113
|
+
const sseCount = async (alias: string) => (await api(nodes.a.token, "GET", "/api/stats/sse")).body.sessions[`${NET}:${alias}`] ?? 0;
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
117
|
+
process.env.HOST = "127.0.0.1";
|
|
118
|
+
const owner = register(`nic_owner_${stamp}`, "NodeConflictOwner123!", undefined, "seed");
|
|
119
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
|
|
120
|
+
for (const key of ["a", "b", "c", "d", "e"]) {
|
|
121
|
+
const alias = `nic-${key}-${stamp}`;
|
|
122
|
+
const nodeId = `n_nic_${key}_${stamp}`;
|
|
123
|
+
const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
|
|
124
|
+
expect(minted.ok).toBe(true);
|
|
125
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
126
|
+
}
|
|
127
|
+
const mod: any = await import("./server.js");
|
|
128
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
129
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
130
|
+
for (const n of Object.values(nodes)) expect((await reportStatus(n, "idle")).ok).toBe(true);
|
|
131
|
+
}, 30_000);
|
|
132
|
+
|
|
133
|
+
afterAll(() => {
|
|
134
|
+
try { server?.stop?.(true); } catch {}
|
|
135
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("#507 two copies of one node (same token, same alias)", () => {
|
|
139
|
+
const subs: Record<string, Sub> = {};
|
|
140
|
+
|
|
141
|
+
test("newest connection wins: the older node stream is told why and closed", async () => {
|
|
142
|
+
const n = nodes.a;
|
|
143
|
+
subs.observer = subscribe(`/events/network/${NET}`, ownerToken);
|
|
144
|
+
subs.monitor = subscribe(`/events/${encodeURIComponent(n.alias)}?network_id=${NET}`, ownerToken);
|
|
145
|
+
await subs.observer.ready; await subs.monitor.ready;
|
|
146
|
+
subs.copyA = nodeStream(n, { "User-Agent": "copy-A" });
|
|
147
|
+
await subs.copyA.ready;
|
|
148
|
+
expect(await sseCount(n.alias)).toBe(2); // copy A + dashboard monitor
|
|
149
|
+
|
|
150
|
+
subs.copyB = nodeStream(n, { "User-Agent": "copy-B" });
|
|
151
|
+
await subs.copyB.ready;
|
|
152
|
+
await until(() => subs.copyA.done, "copy A's stream to be closed");
|
|
153
|
+
const notice = subs.copyA.events.find((e) => e.type === "node_connection_superseded");
|
|
154
|
+
expect(notice).toBeTruthy();
|
|
155
|
+
expect(notice.reason).toBe("superseded_by_new_connection");
|
|
156
|
+
expect(notice.instance_match).toBe("unknown");
|
|
157
|
+
expect(subs.copyB.done).toBe(false);
|
|
158
|
+
expect(subs.monitor.done).toBe(false); // a monitor is never superseded
|
|
159
|
+
expect(await sseCount(n.alias)).toBe(2); // copy B + monitor
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a new task reaches exactly one node subscriber (the monitor still sees it)", async () => {
|
|
163
|
+
const r = await sendTask(nodes.a.alias);
|
|
164
|
+
expect(r.status).toBe(200);
|
|
165
|
+
expect(r.body.ok).toBe(true);
|
|
166
|
+
await until(() => newTasks(subs.copyB).length >= 1 && newTasks(subs.monitor).length >= 1, "new_task on copy B + monitor");
|
|
167
|
+
await settle();
|
|
168
|
+
expect(newTasks(subs.copyB).length).toBe(1);
|
|
169
|
+
expect(newTasks(subs.copyA).length).toBe(0);
|
|
170
|
+
expect(newTasks(subs.monitor).length).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("node_identity_conflict reaches the observer stream and /api/stats/sse, with remotes and no token", async () => {
|
|
174
|
+
await until(() => subs.observer.events.some((e) => e.type === "node_identity_conflict"), "observer conflict event");
|
|
175
|
+
const ev = subs.observer.events.find((e) => e.type === "node_identity_conflict");
|
|
176
|
+
expect(ev.alias).toBe(nodes.a.alias);
|
|
177
|
+
expect(ev.node_id).toBe(nodes.a.nodeId);
|
|
178
|
+
expect(ev.policy).toBe("newest_connection_wins");
|
|
179
|
+
expect(ev.superseded.user_agent).toBe("copy-A");
|
|
180
|
+
expect(ev.current.user_agent).toBe("copy-B");
|
|
181
|
+
expect(ev.superseded.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
182
|
+
expect(ev.current.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
183
|
+
expect(ev.superseded.remote).not.toBe(ev.current.remote);
|
|
184
|
+
const stats = await api(nodes.a.token, "GET", "/api/stats/sse");
|
|
185
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === nodes.a.alias);
|
|
186
|
+
expect(mine.length).toBe(1);
|
|
187
|
+
expect(mine[0].network_id).toBe(NET);
|
|
188
|
+
const raw = JSON.stringify(stats.body) + JSON.stringify(ev);
|
|
189
|
+
expect(raw).not.toContain("ntok_");
|
|
190
|
+
expect(raw).not.toContain(nodes.a.token);
|
|
191
|
+
const audit = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_identity_conflict' AND target_id = ?1", nodes.a.nodeId);
|
|
192
|
+
expect(audit?.n).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("offline only when the last node connection leaves; tasks in between are delivered", async () => {
|
|
196
|
+
const n = nodes.a;
|
|
197
|
+
// Copy A (the superseded one) shuts down and says so.
|
|
198
|
+
const fromA = await reportStatus(n, "offline");
|
|
199
|
+
expect(fromA.ok).toBe(true);
|
|
200
|
+
expect(fromA.offline_deferred).toBe(true);
|
|
201
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
202
|
+
const r = await sendTask(n.alias);
|
|
203
|
+
expect(r.status).toBe(200);
|
|
204
|
+
expect(r.body.ok).toBe(true);
|
|
205
|
+
expect(r.body.error).toBeUndefined();
|
|
206
|
+
await until(() => newTasks(subs.copyB).length >= 2, "second new_task on copy B");
|
|
207
|
+
|
|
208
|
+
// Copy B stops too: it reports offline while its stream is still open, then exits.
|
|
209
|
+
const fromB = await reportStatus(n, "offline");
|
|
210
|
+
expect(fromB.offline_deferred).toBe(true);
|
|
211
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
212
|
+
subs.copyB.abort();
|
|
213
|
+
await until(() => storedStatus(n.alias) === "offline", "deferred offline applied once the last node stream closed");
|
|
214
|
+
expect((await sendTask(n.alias)).body.error).toBe("alias_offline");
|
|
215
|
+
subs.monitor.abort(); subs.observer.abort();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("#507 what is and is not a conflict", () => {
|
|
220
|
+
test("same instance id = a reconnect of the same process: replaced, no conflict recorded", async () => {
|
|
221
|
+
const n = nodes.b;
|
|
222
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
223
|
+
await first.ready;
|
|
224
|
+
const second = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
225
|
+
await second.ready;
|
|
226
|
+
await until(() => first.done, "old stream replaced");
|
|
227
|
+
expect(first.events.find((e) => e.type === "node_connection_superseded")?.reason).toBe("replaced_by_reconnect");
|
|
228
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
229
|
+
expect(stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias)).toEqual([]);
|
|
230
|
+
// No conflict → a stop is exactly as before: offline at once.
|
|
231
|
+
const r = await reportStatus(n, "offline");
|
|
232
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
233
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
234
|
+
second.abort();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("different instance ids are reported as instance_match=different", async () => {
|
|
238
|
+
const n = nodes.c;
|
|
239
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-c-host1" });
|
|
240
|
+
await first.ready;
|
|
241
|
+
const second = subscribe(`/events/${encodeURIComponent(n.alias)}?instance_id=inst-c-host2`, n.token);
|
|
242
|
+
await second.ready;
|
|
243
|
+
await until(() => first.done, "old stream superseded");
|
|
244
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
245
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias);
|
|
246
|
+
expect(mine.length).toBe(1);
|
|
247
|
+
expect(mine[0].instance_match).toBe("different");
|
|
248
|
+
expect(mine[0].superseded.instance_id).toBe("inst-c-host1");
|
|
249
|
+
expect(mine[0].current.instance_id).toBe("inst-c-host2");
|
|
250
|
+
second.abort();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("another node's token watching this alias never supersedes the node", async () => {
|
|
254
|
+
const n = nodes.d;
|
|
255
|
+
const own = nodeStream(n);
|
|
256
|
+
await own.ready;
|
|
257
|
+
const watcher = subscribe(`/events/${encodeURIComponent(n.alias)}`, nodes.e.token);
|
|
258
|
+
await watcher.ready;
|
|
259
|
+
await settle();
|
|
260
|
+
expect(own.done).toBe(false);
|
|
261
|
+
expect(own.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
|
|
262
|
+
watcher.abort();
|
|
263
|
+
// And a plain single-node stop (no conflict) is offline immediately, as before.
|
|
264
|
+
const r = await reportStatus(n, "offline");
|
|
265
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
266
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
267
|
+
own.abort();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #507 — two live processes holding the same node identity (a node directory
|
|
2
|
+
// copied verbatim and started elsewhere: same node token, same alias).
|
|
3
|
+
//
|
|
4
|
+
// push.ts enforces "at most one node-scoped SSE subscriber per (network, alias)",
|
|
5
|
+
// newest connection wins (policy + rationale next to enforceSingleNodeSubscriber).
|
|
6
|
+
// This module is the Hub-side bookkeeping around that:
|
|
7
|
+
//
|
|
8
|
+
// 1. node_identity_conflict — every supersede between connections that are not
|
|
9
|
+
// provably the same process is logged, written to audit_log, pushed to the
|
|
10
|
+
// network observer stream (dashboard) and kept in a small ring that
|
|
11
|
+
// GET /api/stats/sse returns. Carries both connections' remote info; never
|
|
12
|
+
// a token.
|
|
13
|
+
//
|
|
14
|
+
// 2. offline while another copy is still connected — a copy's shutdown
|
|
15
|
+
// report_status(offline) used to mark the node offline even though the other
|
|
16
|
+
// copy was connected, so new tasks got alias_offline. While a conflict is
|
|
17
|
+
// recent for that alias and a node-scoped subscriber is still connected, the
|
|
18
|
+
// offline is DEFERRED: stored status stays as it was, and the offline is
|
|
19
|
+
// applied only when the last node-scoped subscriber leaves (or dropped when the
|
|
20
|
+
// surviving copy reports a fresh status). Outside a conflict window nothing
|
|
21
|
+
// changes: a normal single-node stop marks offline immediately, as before.
|
|
22
|
+
|
|
23
|
+
import { db, logAudit } from "./db.js";
|
|
24
|
+
import {
|
|
25
|
+
hasNodeSubscriber,
|
|
26
|
+
onNodeIdentityConflict,
|
|
27
|
+
onNodeSubscribersDrained,
|
|
28
|
+
pushEvent,
|
|
29
|
+
pushNetworkObserverEvent,
|
|
30
|
+
type NodeIdentityConflict,
|
|
31
|
+
} from "./push.js";
|
|
32
|
+
|
|
33
|
+
/** How long after a conflict an offline report is treated as "maybe the other copy". */
|
|
34
|
+
export const CONFLICT_WINDOW_MS = 10 * 60 * 1000;
|
|
35
|
+
const RING_MAX = 50;
|
|
36
|
+
/** Observer/audit emission is rate-limited per alias (logs are not). A flapping
|
|
37
|
+
* pair supersedes about once a second; one event a minute is enough to see it. */
|
|
38
|
+
const EMIT_EVERY_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const ring: NodeIdentityConflict[] = [];
|
|
41
|
+
const lastConflictAt = new Map<string, number>();
|
|
42
|
+
const lastEmittedAt = new Map<string, number>();
|
|
43
|
+
const pendingOffline = new Map<string, { resumeId: string; at: number }>();
|
|
44
|
+
|
|
45
|
+
const k = (alias: string, networkId: string | null | undefined) => `${networkId || "global"}:${alias}`;
|
|
46
|
+
|
|
47
|
+
function publicInfo(i: NodeIdentityConflict["current"]) {
|
|
48
|
+
return {
|
|
49
|
+
instance_id: i.instanceId,
|
|
50
|
+
remote: i.remote,
|
|
51
|
+
user_agent: i.userAgent,
|
|
52
|
+
connected_at: new Date(i.connectedAt).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function conflictPayload(c: NodeIdentityConflict) {
|
|
57
|
+
return {
|
|
58
|
+
type: "node_identity_conflict",
|
|
59
|
+
alias: c.alias,
|
|
60
|
+
node_id: c.nodeId,
|
|
61
|
+
instance_match: c.instanceMatch,
|
|
62
|
+
policy: "newest_connection_wins",
|
|
63
|
+
superseded: publicInfo(c.superseded),
|
|
64
|
+
current: publicInfo(c.current),
|
|
65
|
+
at: new Date(c.at).toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleConflict(c: NodeIdentityConflict): void {
|
|
70
|
+
const key = k(c.alias, c.networkId);
|
|
71
|
+
lastConflictAt.set(key, c.at);
|
|
72
|
+
ring.push(c);
|
|
73
|
+
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
74
|
+
const payload = conflictPayload(c);
|
|
75
|
+
console.warn(`[node_identity_conflict] ${c.key} node_id=${c.nodeId ?? "?"} instance_match=${c.instanceMatch} ` +
|
|
76
|
+
`superseded=${c.superseded.remote ?? "?"}(${c.superseded.instanceId ?? "no-instance-id"}) ` +
|
|
77
|
+
`by=${c.current.remote ?? "?"}(${c.current.instanceId ?? "no-instance-id"}) — ` +
|
|
78
|
+
`two processes are using this node's identity (copied node directory?); newest connection kept`);
|
|
79
|
+
const last = lastEmittedAt.get(key) ?? 0;
|
|
80
|
+
if (c.at - last < EMIT_EVERY_MS) return;
|
|
81
|
+
lastEmittedAt.set(key, c.at);
|
|
82
|
+
pushNetworkObserverEvent(c.networkId, payload);
|
|
83
|
+
logAudit(null, null, "node_identity_conflict", "node", c.nodeId ?? c.alias, JSON.stringify(payload), c.current.remote ?? undefined, c.networkId ?? undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleDrained(alias: string, networkId: string | null): void {
|
|
87
|
+
const key = k(alias, networkId);
|
|
88
|
+
const pending = pendingOffline.get(key);
|
|
89
|
+
if (!pending) return;
|
|
90
|
+
pendingOffline.delete(key);
|
|
91
|
+
try {
|
|
92
|
+
db.run(
|
|
93
|
+
"UPDATE sessions SET status = 'offline', updated_at = datetime('now') WHERE resume_id = ?1 AND alias = ?2 AND network_id = ?3",
|
|
94
|
+
[pending.resumeId, alias, networkId ?? "default"],
|
|
95
|
+
);
|
|
96
|
+
console.log(`[node_identity_conflict] ${key} last node connection gone → deferred offline applied`);
|
|
97
|
+
pushEvent(alias, { type: "status_update", alias, status: "offline", progress: null, host: null, process_telemetry: null }, networkId);
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
console.log(`[node_identity_conflict] deferred offline failed for ${key}: ${e?.message || e}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onNodeIdentityConflict(handleConflict);
|
|
104
|
+
onNodeSubscribersDrained(handleDrained);
|
|
105
|
+
|
|
106
|
+
/** report_status hook. Returns true when an offline report must NOT be stored now
|
|
107
|
+
* (another copy of this node is still connected); it is then applied when the last
|
|
108
|
+
* node-scoped subscriber leaves. Any non-offline report clears a pending offline. */
|
|
109
|
+
export function deferOfflineIfAnotherCopyConnected(
|
|
110
|
+
alias: string,
|
|
111
|
+
networkId: string | null | undefined,
|
|
112
|
+
status: string,
|
|
113
|
+
resumeId: string,
|
|
114
|
+
now = Date.now(),
|
|
115
|
+
): boolean {
|
|
116
|
+
const key = k(alias, networkId);
|
|
117
|
+
if (status !== "offline") {
|
|
118
|
+
pendingOffline.delete(key);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const conflictAt = lastConflictAt.get(key);
|
|
122
|
+
if (conflictAt === undefined || now - conflictAt > CONFLICT_WINDOW_MS) return false;
|
|
123
|
+
if (!hasNodeSubscriber(alias, networkId)) return false;
|
|
124
|
+
pendingOffline.set(key, { resumeId, at: now });
|
|
125
|
+
console.warn(`[node_identity_conflict] ${key} report_status(offline) deferred: a node connection is still open ` +
|
|
126
|
+
`and a second copy was seen ${Math.round((now - conflictAt) / 1000)}s ago`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Recent conflicts, newest last, optionally limited to one network. */
|
|
131
|
+
export function recentNodeIdentityConflicts(networkId?: string | null) {
|
|
132
|
+
return ring
|
|
133
|
+
.filter((c) => networkId === undefined || c.networkId === networkId)
|
|
134
|
+
.map((c) => ({ network_id: c.networkId, ...conflictPayload(c) }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function __resetNodeIdentityConflictsForTest(): void {
|
|
138
|
+
ring.length = 0;
|
|
139
|
+
lastConflictAt.clear();
|
|
140
|
+
lastEmittedAt.clear();
|
|
141
|
+
pendingOffline.clear();
|
|
142
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// report_status must not relabel a nodes row that belongs to another node.
|
|
2
|
+
//
|
|
3
|
+
// Shape seen in production: node A's config carries a node_id that another
|
|
4
|
+
// node B reported first (a copied config / a colliding hand-backfilled id).
|
|
5
|
+
// The row is B's alias + B's config_path, unowned (pre-RFC-036). When A starts
|
|
6
|
+
// reporting node_id, upsertNodeWithSec1Guard's COALESCE would overwrite the
|
|
7
|
+
// alias with A's — B's row silently becomes A's. identity_mismatch refuses it.
|
|
8
|
+
//
|
|
9
|
+
// Run with: COMMHUB_DB=/tmp/identity-mismatch.db bun test src/node-identity-mismatch.test.ts
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_identity";
|
|
16
|
+
|
|
17
|
+
function row(node_id: string, alias: string, config_path: string | null, owner: string | null = null) {
|
|
18
|
+
db.run(
|
|
19
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, config_path, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server-sdk', ?3, ?4, ?5)`,
|
|
20
|
+
[node_id, alias, config_path, NET, owner],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const read = (id: string) => db.get<{ alias: string; runtime: string | null; config_path: string | null }>(
|
|
24
|
+
"SELECT alias, runtime, config_path FROM nodes WHERE node_id = ?1", id,
|
|
25
|
+
)!;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => { db.run("DELETE FROM nodes"); });
|
|
28
|
+
|
|
29
|
+
describe("upsertNodeWithSec1Guard — identity_mismatch", () => {
|
|
30
|
+
test("different alias + different config_path ⇒ refused, row untouched", () => {
|
|
31
|
+
row("n_shared01", "node-b", "/work/b/.anet/nodes/node-b/config.json");
|
|
32
|
+
const out = upsertNodeWithSec1Guard({
|
|
33
|
+
node_id: "n_shared01", callerNetworkId: NET, alias: "node-a", runtime: "claude-code",
|
|
34
|
+
config_path: "/work/a/.anet/nodes/node-a/config.json",
|
|
35
|
+
});
|
|
36
|
+
expect(out).toMatchObject({ result: "refused", reason: "identity_mismatch" });
|
|
37
|
+
expect(read("n_shared01")).toEqual({ alias: "node-b", runtime: "codex-app-server-sdk", config_path: "/work/b/.anet/nodes/node-b/config.json" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("same alias, different config_path (the node moved dirs) ⇒ updated", () => {
|
|
41
|
+
row("n_moved01", "node-a", "/old/.anet/nodes/node-a/config.json");
|
|
42
|
+
const out = upsertNodeWithSec1Guard({ node_id: "n_moved01", callerNetworkId: NET, alias: "node-a", config_path: "/new/.anet/nodes/node-a/config.json" });
|
|
43
|
+
expect(out.result).toBe("updated");
|
|
44
|
+
expect(read("n_moved01").config_path).toBe("/new/.anet/nodes/node-a/config.json");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("different alias, same config_path ⇒ updated (not the collision shape)", () => {
|
|
48
|
+
row("n_same01", "old-name", "/w/.anet/nodes/x/config.json");
|
|
49
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_same01", callerNetworkId: NET, alias: "new-name", config_path: "/w/.anet/nodes/x/config.json" }).result).toBe("updated");
|
|
50
|
+
expect(read("n_same01").alias).toBe("new-name");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("row without config_path (token-mint row) ⇒ first reporter fills it in", () => {
|
|
54
|
+
row("n_mint01", "node-a", null);
|
|
55
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_mint01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("updated");
|
|
56
|
+
expect(read("n_mint01").config_path).toBe("/w/a/config.json");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reporter without config_path ⇒ no evidence, legacy behaviour kept", () => {
|
|
60
|
+
row("n_nocfg01", "node-b", "/w/b/config.json");
|
|
61
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_nocfg01", callerNetworkId: NET, alias: "node-a" }).result).toBe("updated");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("new node_id ⇒ inserted", () => {
|
|
65
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_fresh01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("inserted");
|
|
66
|
+
expect(read("n_fresh01").alias).toBe("node-a");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("owner mismatch still wins first (existing gate unchanged)", () => {
|
|
70
|
+
row("n_owned01", "node-b", "/w/b/config.json", "usr_b");
|
|
71
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_owned01", callerNetworkId: NET, callerUserId: "usr_a", alias: "node-a", config_path: "/w/a/config.json" }))
|
|
72
|
+
.toMatchObject({ result: "refused", reason: "owner_mismatch" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
childNodeIdForCreateRequest,
|
|
4
|
+
buildControllableMap,
|
|
5
|
+
isLifecycleControllable,
|
|
6
|
+
} from "./node-lifecycle-controllable.js";
|
|
7
|
+
|
|
8
|
+
// app#196 —— 桌面端对 218 个节点里的 207 个(95%)显示了「停止/重启/删除」,
|
|
9
|
+
// 而那三个工具的 schema 是 regex(/^node_[a-z0-9_-]+$/),那 207 个是 n_ 开头,
|
|
10
|
+
// 点下去只会看到一段 zod 正则原文。
|
|
11
|
+
describe("childNodeIdForCreateRequest —— 推导规则", () => {
|
|
12
|
+
test("cr_ 前缀 → node_ 前缀,其余部分逐字保留", () => {
|
|
13
|
+
// 🔴 这一对是 2026-08-28 生产 hub 日志里真实的 create-node finalize 行,
|
|
14
|
+
// 不是我按注释造的。
|
|
15
|
+
expect(childNodeIdForCreateRequest("cr_59723b24-8372-4270-9b3c-1552e592a09f"))
|
|
16
|
+
.toBe("node_59723b24-8372-4270-9b3c-1552e592a09f");
|
|
17
|
+
});
|
|
18
|
+
test("不是 cr_ 开头的一律 null —— 不猜、不截断", () => {
|
|
19
|
+
expect(childNodeIdForCreateRequest("node_abc")).toBeNull();
|
|
20
|
+
expect(childNodeIdForCreateRequest("n_abc")).toBeNull();
|
|
21
|
+
expect(childNodeIdForCreateRequest("abc")).toBeNull();
|
|
22
|
+
expect(childNodeIdForCreateRequest("cr_")).toBeNull(); // 只有前缀,没有主体
|
|
23
|
+
});
|
|
24
|
+
test("非字符串输入不抛,返回 null", () => {
|
|
25
|
+
for (const v of [undefined, null, 123, {}, []]) {
|
|
26
|
+
expect(childNodeIdForCreateRequest(v as any)).toBeNull();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("isLifecycleControllable —— 判据不是 id 前缀", () => {
|
|
32
|
+
const map = buildControllableMap([
|
|
33
|
+
{ request_id: "cr_aaa", daemon_node_id: "node_daemon_x" },
|
|
34
|
+
{ request_id: "cr_bbb", daemon_node_id: "node_daemon_y" },
|
|
35
|
+
{ request_id: "not-a-request", daemon_node_id: "node_daemon_z" }, // 应被丢弃
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
test("有创建记录的 → 可控", () => {
|
|
39
|
+
expect(isLifecycleControllable("node_aaa", null, map)).toBe(true);
|
|
40
|
+
expect(map.get("node_aaa")).toBe("node_daemon_x");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("🔴 光有 node_ 前缀但没有创建记录 → 不可控", () => {
|
|
44
|
+
// 这条是本模块存在的理由:前缀是实现细节,不是判据。
|
|
45
|
+
expect(isLifecycleControllable("node_never_created", null, map)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("手工起的 n_ 节点 → 不可控(那 207 个)", () => {
|
|
49
|
+
expect(isLifecycleControllable("n_22b777bf", null, map)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("🔴 daemon 自己可控 —— 它在 node_create_requests 里没有记录", () => {
|
|
53
|
+
// 漏掉这条会把 3 个 daemon 从可控名单里误删。
|
|
54
|
+
expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", "host_supervisor", map)).toBe(true);
|
|
55
|
+
// 正控:同一个 id 但 role 不是 host_supervisor ⇒ 仍不可控,
|
|
56
|
+
// 证明上面那条过是因为 role,不是因为 id 恰好带 node_ 前缀。
|
|
57
|
+
expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", null, map)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("坏的 request_id 不会污染映射", () => {
|
|
61
|
+
expect(map.has("node_not-a-request")).toBe(false);
|
|
62
|
+
expect(map.size).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
|
|
3
|
+
*
|
|
4
|
+
* 抽成独立模块而不是写在 /api/nodes 的路由里,因为路由里的内联逻辑测不到 ——
|
|
5
|
+
* 现有的 api-nodes-shape.test.ts 是直跑 handler 的 SQL,拿不到映射步骤。
|
|
6
|
+
* 🔴 「不可测」本身就是一个设计信号,不是测试的问题。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 子节点 id 由创建请求 id 确定性推导。
|
|
10
|
+
*
|
|
11
|
+
* 🔴 这条规则我用真实一对验过,不是只读注释:
|
|
12
|
+
* cr_59723b24-8372-4270-9b3c-1552e592a09f → node_59723b24-8372-4270-9b3c-…
|
|
13
|
+
* (2026-08-28 生产 hub 日志的 `create-node finalize` 行)
|
|
14
|
+
*
|
|
15
|
+
* 非 `cr_` 开头的一律返回 null —— 不猜,不截断。 */
|
|
16
|
+
export function childNodeIdForCreateRequest(requestId: unknown): string | null {
|
|
17
|
+
if (typeof requestId !== "string") return null;
|
|
18
|
+
if (!requestId.startsWith("cr_")) return null;
|
|
19
|
+
const rest = requestId.slice(3);
|
|
20
|
+
if (!rest) return null;
|
|
21
|
+
return `node_${rest}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 由创建记录建出「child node_id → daemon node_id」的映射。 */
|
|
25
|
+
export function buildControllableMap(
|
|
26
|
+
createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
|
|
27
|
+
): Map<string, string> {
|
|
28
|
+
const m = new Map<string, string>();
|
|
29
|
+
for (const cr of createRequests) {
|
|
30
|
+
const child = childNodeIdForCreateRequest(cr?.request_id);
|
|
31
|
+
if (!child) continue;
|
|
32
|
+
m.set(child, typeof cr?.daemon_node_id === "string" ? cr.daemon_node_id : "");
|
|
33
|
+
}
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一个节点能不能走生命周期操作。
|
|
38
|
+
*
|
|
39
|
+
* 🔴 判据**不是 id 前缀**。前缀是实现细节;判据是 node_create_requests 里真有创建记录。
|
|
40
|
+
*
|
|
41
|
+
* 例外:daemon 自己(role=host_supervisor)也能被 restart/stop,而它在
|
|
42
|
+
* node_create_requests 里**没有**记录(它不是被别的 daemon 创建的)—— 单独放行。
|
|
43
|
+
* 漏掉这一条会把可控的 daemon 判成不可控。 */
|
|
44
|
+
export function isLifecycleControllable(
|
|
45
|
+
nodeId: unknown,
|
|
46
|
+
role: unknown,
|
|
47
|
+
controllable: Map<string, string>,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (role === "host_supervisor") return true;
|
|
50
|
+
return typeof nodeId === "string" && controllable.has(nodeId);
|
|
51
|
+
}
|