@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +622 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +286 -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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/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/push.ts +163 -7
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -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 +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -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 +437 -0
- package/src/server.ts +3591 -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/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -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 +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2952 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Boundary validation for node DISPLAY attributes (display_name / team /
|
|
2
|
+
// tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
|
|
3
|
+
// that decides what a client is allowed to store, and it trusts nothing.
|
|
4
|
+
//
|
|
5
|
+
// Two different postures on purpose:
|
|
6
|
+
//
|
|
7
|
+
// display_name / team — SCALAR fields the user typed. A wrong TYPE here
|
|
8
|
+
// is a client bug, so it is REJECTED (400) rather than coerced: silently
|
|
9
|
+
// turning `{}` into "[object Object]" would persist garbage the user
|
|
10
|
+
// never typed and can't explain.
|
|
11
|
+
//
|
|
12
|
+
// tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
|
|
13
|
+
// wire contract wants junk dropped instead of failing the whole request,
|
|
14
|
+
// so one fat-fingered entry from a dashboard doesn't 400 the save. Each
|
|
15
|
+
// element is narrowed independently (typeof → trim → length cap →
|
|
16
|
+
// dedup), so nothing non-string can reach the database.
|
|
17
|
+
|
|
18
|
+
/** Max stored length of display_name / team. */
|
|
19
|
+
export const MAX_ATTR_LEN = 120;
|
|
20
|
+
/** Max stored length of a single tag. */
|
|
21
|
+
export const MAX_TAG_LEN = 64;
|
|
22
|
+
/** Max number of tags kept per node. */
|
|
23
|
+
export const MAX_TAGS = 16;
|
|
24
|
+
|
|
25
|
+
export type ScalarAttrResult =
|
|
26
|
+
| { ok: true; value: string | null }
|
|
27
|
+
| { ok: false; reason: string };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Narrow an optional scalar display attribute.
|
|
31
|
+
* undefined → not part of this patch (caller leaves the column alone)
|
|
32
|
+
* null / "" → explicit clear
|
|
33
|
+
* non-string → rejected (never coerced)
|
|
34
|
+
* over-long → rejected (the user should see it, not silently lose text)
|
|
35
|
+
*/
|
|
36
|
+
export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
|
|
37
|
+
if (raw === undefined) return { ok: true, value: null };
|
|
38
|
+
if (raw === null) return { ok: true, value: null };
|
|
39
|
+
if (typeof raw !== "string") {
|
|
40
|
+
return { ok: false, reason: `${field} must be a string or null` };
|
|
41
|
+
}
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed.length === 0) return { ok: true, value: null };
|
|
44
|
+
if (trimmed.length > MAX_ATTR_LEN) {
|
|
45
|
+
return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
|
|
46
|
+
}
|
|
47
|
+
// Control characters would corrupt any log line / table cell that renders
|
|
48
|
+
// the value; strip rather than reject (they are almost always a paste
|
|
49
|
+
// artefact, not user intent).
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
52
|
+
return { ok: true, value: clean.length > 0 ? clean : null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Narrow an untrusted tags array. NEVER throws, never rejects the request:
|
|
57
|
+
* unusable entries are dropped. Returns the canonical list to store.
|
|
58
|
+
*/
|
|
59
|
+
export function narrowTags(raw: unknown): string[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const item of raw) {
|
|
64
|
+
if (typeof item !== "string") continue; // numbers/objects/arrays/null
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
67
|
+
if (t.length === 0) continue;
|
|
68
|
+
if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
|
|
69
|
+
const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
|
|
70
|
+
if (seen.has(key)) continue;
|
|
71
|
+
seen.add(key);
|
|
72
|
+
out.push(t);
|
|
73
|
+
if (out.length >= MAX_TAGS) break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse the stored tags column back into an array. Storage is JSON text;
|
|
79
|
+
* anything unparseable (hand-edited DB, older format) reads as empty so a
|
|
80
|
+
* dashboard can always `.map()` without a guard. */
|
|
81
|
+
export function parseStoredTags(stored: unknown): string[] {
|
|
82
|
+
if (typeof stored !== "string" || stored.length === 0) return [];
|
|
83
|
+
try {
|
|
84
|
+
return narrowTags(JSON.parse(stored));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
|
|
2
|
+
//
|
|
3
|
+
// These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
|
|
4
|
+
// precedent they are deliberately NOT part of the RFC-024 config-apply
|
|
5
|
+
// pipeline: agent-node has nothing to consume, so pushing them through
|
|
6
|
+
// config revision/ack would ring a doorbell for a field the node does not
|
|
7
|
+
// understand and could wedge the config revision.
|
|
8
|
+
//
|
|
9
|
+
// They DO need concurrency protection (two dashboards editing the same
|
|
10
|
+
// node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
|
|
11
|
+
// that is independent of `config_revision` — editing a tag must never bump
|
|
12
|
+
// the node's *config* revision.
|
|
13
|
+
//
|
|
14
|
+
// Every test drives the REAL HTTP endpoint against a booted server, not a
|
|
15
|
+
// helper: the contract under test is the wire shape + status codes.
|
|
16
|
+
//
|
|
17
|
+
// NOTE (test isolation): this file boots its own Bun.serve, same pattern as
|
|
18
|
+
// uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
|
|
19
|
+
// are per-file runs by design (single-process module-singleton conflict,
|
|
20
|
+
// tracked in #434) — run this file on its own.
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync } from "fs";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
|
|
27
|
+
const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
|
|
28
|
+
process.env.COMMHUB_DB ||= tmpDb;
|
|
29
|
+
const PORT = 23000 + Math.floor(Math.random() * 1500);
|
|
30
|
+
process.env.PORT = String(PORT);
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
|
|
33
|
+
const { db } = await import("./db.js");
|
|
34
|
+
const { issueUserToken } = await import("./auth.js");
|
|
35
|
+
const { bootServer } = await import("./server.js");
|
|
36
|
+
|
|
37
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
38
|
+
const NET = "net_attrs";
|
|
39
|
+
const USER = "u_attrs";
|
|
40
|
+
/** A node row seeded the way a PRE-MIGRATION row looks: none of the new
|
|
41
|
+
* columns are written, so they are NULL / default at read time. */
|
|
42
|
+
const LEGACY_NODE = "node_attrs_legacy";
|
|
43
|
+
const LEGACY_ALIAS = "attrs-legacy-node";
|
|
44
|
+
|
|
45
|
+
let token = "";
|
|
46
|
+
let server: { stop: (force?: boolean) => void } | null = null;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
|
|
50
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
|
|
51
|
+
db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
|
|
52
|
+
// Legacy row: only the columns that existed before this change.
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
|
|
55
|
+
VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
|
|
56
|
+
[LEGACY_NODE, LEGACY_ALIAS, NET],
|
|
57
|
+
);
|
|
58
|
+
token = issueUserToken(USER, "attrs-test").token;
|
|
59
|
+
server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
|
|
60
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
try { server?.stop(true); } catch { /* already down */ }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function putAttrs(ref: string, body: unknown) {
|
|
68
|
+
const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
|
|
69
|
+
method: "PUT",
|
|
70
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function listNodes() {
|
|
77
|
+
const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
|
|
78
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
79
|
+
});
|
|
80
|
+
const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
|
|
81
|
+
return j.nodes ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("node display attrs — backward compatibility with existing rows", () => {
|
|
85
|
+
test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
|
|
86
|
+
const rows = await listNodes();
|
|
87
|
+
const row = rows.find((r) => r.node_id === LEGACY_NODE);
|
|
88
|
+
expect(row).toBeDefined();
|
|
89
|
+
// Must not blow up and must not omit the keys — the dashboard reads
|
|
90
|
+
// them unconditionally.
|
|
91
|
+
expect(row!).toHaveProperty("display_name");
|
|
92
|
+
expect(row!).toHaveProperty("team");
|
|
93
|
+
expect(row!).toHaveProperty("tags");
|
|
94
|
+
expect(row!.display_name ?? null).toBeNull();
|
|
95
|
+
expect(row!.team ?? null).toBeNull();
|
|
96
|
+
// tags normalises to an empty array rather than null/undefined so the
|
|
97
|
+
// dashboard can `.map()` without a guard.
|
|
98
|
+
expect(row!.tags).toEqual([]);
|
|
99
|
+
expect(row!.attrs_revision).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("node display attrs — write then read back", () => {
|
|
104
|
+
test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
|
|
105
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
106
|
+
base_attrs_revision: 0,
|
|
107
|
+
display_name: "研发一号机",
|
|
108
|
+
team: "platform",
|
|
109
|
+
tags: ["prod", "gpu"],
|
|
110
|
+
});
|
|
111
|
+
expect(w.status).toBe(200);
|
|
112
|
+
expect(w.json.ok).toBe(true);
|
|
113
|
+
expect(w.json.attrs_revision).toBe(1);
|
|
114
|
+
|
|
115
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
116
|
+
expect(row.display_name).toBe("研发一号机");
|
|
117
|
+
expect(row.team).toBe("platform");
|
|
118
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
119
|
+
expect(row.attrs_revision).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
|
|
123
|
+
const before = db.get<{ config_revision: number }>(
|
|
124
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
125
|
+
const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
|
|
126
|
+
expect(w.status).toBe(200);
|
|
127
|
+
const after = db.get<{ config_revision: number }>(
|
|
128
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
129
|
+
expect(after).toBe(before);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("partial patch leaves untouched fields alone", async () => {
|
|
133
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
134
|
+
// team was just changed to 'infra'; display_name/tags must survive.
|
|
135
|
+
expect(row.team).toBe("infra");
|
|
136
|
+
expect(row.display_name).toBe("研发一号机");
|
|
137
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("node display attrs — CAS concurrency guard", () => {
|
|
142
|
+
test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
|
|
143
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
144
|
+
const stale = await putAttrs(LEGACY_ALIAS, {
|
|
145
|
+
base_attrs_revision: 0, // deliberately stale
|
|
146
|
+
team: "should-not-land",
|
|
147
|
+
});
|
|
148
|
+
expect(stale.status).toBe(409);
|
|
149
|
+
expect(stale.json.ok).toBe(false);
|
|
150
|
+
expect(stale.json.error).toBe("attrs_revision_conflict");
|
|
151
|
+
expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
|
|
152
|
+
|
|
153
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
154
|
+
expect(after.team).toBe(before.team); // unchanged
|
|
155
|
+
expect(after.attrs_revision).toBe(before.attrs_revision);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("node display attrs — untrusted input is narrowed at the boundary", () => {
|
|
160
|
+
test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
|
|
161
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
162
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
163
|
+
base_attrs_revision: cur,
|
|
164
|
+
tags: [
|
|
165
|
+
"keep",
|
|
166
|
+
"keep", // duplicate
|
|
167
|
+
" spaced ", // trimmed
|
|
168
|
+
"",
|
|
169
|
+
" ",
|
|
170
|
+
42,
|
|
171
|
+
null,
|
|
172
|
+
{ evil: true },
|
|
173
|
+
["nested"],
|
|
174
|
+
"x".repeat(200), // over-long → dropped or truncated, never stored raw
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
expect(w.status).toBe(200);
|
|
178
|
+
const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
|
|
179
|
+
expect(Array.isArray(tags)).toBe(true);
|
|
180
|
+
expect(tags).toContain("keep");
|
|
181
|
+
expect(tags).toContain("spaced");
|
|
182
|
+
// one "keep", no junk, nothing absurdly long
|
|
183
|
+
expect(tags.filter((t) => t === "keep")).toHaveLength(1);
|
|
184
|
+
expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
|
|
185
|
+
expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("display_name/team: non-string values are rejected, not coerced", async () => {
|
|
189
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
190
|
+
const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
|
|
191
|
+
expect(bad.status).toBe(400);
|
|
192
|
+
expect(bad.json.ok).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("node display attrs — auth + scope", () => {
|
|
197
|
+
test("no token → 401, and the row is untouched", async () => {
|
|
198
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
199
|
+
const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
|
|
200
|
+
method: "PUT",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(401);
|
|
205
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
206
|
+
expect(after.team).toBe(before.team);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("unknown node ref → 404", async () => {
|
|
210
|
+
const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
|
|
211
|
+
expect(r.status).toBe(404);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// #1281 — 子节点生命周期工具的参数名统一(node_id ⇄ child_node_id)。
|
|
2
|
+
//
|
|
3
|
+
// 分叉现状(origin/main 2976adb2):stop_node / start_node / delete_node 用
|
|
4
|
+
// `child_node_id`,restart_node / update_node_config 用 `node_id`,指向同一个
|
|
5
|
+
// 子节点。调用方按一个工具的习惯给另一个传就吃 -32602。修复:5 个工具都同时
|
|
6
|
+
// 接受两个名字(canonical = node_id,child_node_id 兼容 alias),解析收敛到一个
|
|
7
|
+
// 模块级 helper resolveNodeIdArg。
|
|
8
|
+
//
|
|
9
|
+
// 两向 witnessed-red(本文件即判据):
|
|
10
|
+
// · schema 层:每个工具的**历史用名**始终 parse 成功(证明没破已发布契约),
|
|
11
|
+
// **新增别名**在修复前 parse 失败(缺必填字段)、修复后成功。
|
|
12
|
+
// · handler 层:resolveNodeIdArg 纯函数 —— 二选一至少一必填、都传须相等。
|
|
13
|
+
// · 真实 handler 路径:stop_node 用 node_id / child_node_id 都能穿到
|
|
14
|
+
// resolveDaemonForChild(不再被 node_id_required 挡),都传冲突 → node_id_conflict。
|
|
15
|
+
//
|
|
16
|
+
// 对 pre-fix 见红的证明方式(评审可复现):把 tools.ts 里任一工具的
|
|
17
|
+
// `...NODE_ID_ALIAS_FIELDS` 还原成原来的单名字段(如 stop_node 的
|
|
18
|
+
// `child_node_id: z.string()...regex(...)`),本文件对应工具的「新增别名 parse 成功」
|
|
19
|
+
// 断言立即变红 —— 证明该断言有判别力,不是恒真。
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test, beforeAll } from "bun:test";
|
|
22
|
+
import { mkdtempSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
|
|
26
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || (mkdtempSync(join(tmpdir(), "anet-1281-")) + "/commhub.db");
|
|
27
|
+
|
|
28
|
+
import { z } from "zod/v4";
|
|
29
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
30
|
+
import { registerTools, resolveNodeIdArg } from "./tools.js";
|
|
31
|
+
|
|
32
|
+
type ToolHandler = (args: any) => Promise<{ content: { type: "text"; text: string }[] }>;
|
|
33
|
+
|
|
34
|
+
// Capture BOTH the raw zod shape and the handler for every tool, so the
|
|
35
|
+
// schema-layer assertions run against the exact object server.tool received.
|
|
36
|
+
function buildCaptured() {
|
|
37
|
+
const schemas: Record<string, any> = {};
|
|
38
|
+
const handlers: Record<string, ToolHandler> = {};
|
|
39
|
+
const server = new McpServer({ name: "t1281", version: "0" }) as any;
|
|
40
|
+
const origTool = server.tool.bind(server);
|
|
41
|
+
server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
|
|
42
|
+
schemas[name] = schema; handlers[name] = handler;
|
|
43
|
+
return origTool(name, _desc, schema, handler);
|
|
44
|
+
};
|
|
45
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
46
|
+
if (origRegisterTool) {
|
|
47
|
+
server.registerTool = (name: string, cfg: any, handler: ToolHandler) => {
|
|
48
|
+
schemas[name] = cfg?.inputSchema ?? cfg; handlers[name] = handler;
|
|
49
|
+
return origRegisterTool(name, cfg, handler);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
registerTools(server, undefined, null, "user_1281", null, false, null);
|
|
53
|
+
return { schemas, handlers };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let SCHEMAS: Record<string, any> = {};
|
|
57
|
+
let HANDLERS: Record<string, ToolHandler> = {};
|
|
58
|
+
beforeAll(() => { const c = buildCaptured(); SCHEMAS = c.schemas; HANDLERS = c.handlers; });
|
|
59
|
+
|
|
60
|
+
async function call(name: string, args: any) {
|
|
61
|
+
const r = await HANDLERS[name](args);
|
|
62
|
+
return JSON.parse(r.content[0].text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Per tool: the historically-required id name, the newly-added alias, and
|
|
66
|
+
// the OTHER required fields the shape needs so a safeParse isolates the id
|
|
67
|
+
// field rather than tripping on an unrelated missing field.
|
|
68
|
+
const TOOLS = [
|
|
69
|
+
{ name: "stop_node", historic: "child_node_id", added: "node_id", extra: {} },
|
|
70
|
+
{ name: "start_node", historic: "child_node_id", added: "node_id", extra: {} },
|
|
71
|
+
{ name: "delete_node", historic: "child_node_id", added: "node_id", extra: { confirm_alias: "some-alias" } },
|
|
72
|
+
{ name: "restart_node", historic: "node_id", added: "child_node_id", extra: {} },
|
|
73
|
+
{ name: "update_node_config", historic: "node_id", added: "child_node_id", extra: { base_revision: 0, patch: {} } },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const VALID_ID = "node_test_1281";
|
|
77
|
+
|
|
78
|
+
describe("#1281 schema layer — every lifecycle tool accepts BOTH node_id and child_node_id", () => {
|
|
79
|
+
for (const t of TOOLS) {
|
|
80
|
+
test(`${t.name}: historic name '${t.historic}' still parses (published contract intact)`, () => {
|
|
81
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
82
|
+
const r = shape.safeParse({ [t.historic]: VALID_ID, ...t.extra });
|
|
83
|
+
expect(r.success).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test(`${t.name}: added alias '${t.added}' parses (was -32602 pre-#1281 → RED without the fix)`, () => {
|
|
87
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
88
|
+
const r = shape.safeParse({ [t.added]: VALID_ID, ...t.extra });
|
|
89
|
+
expect(r.success).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test(`${t.name}: both names present + equal parses (schema layer permissive; conflict caught in handler)`, () => {
|
|
93
|
+
const shape = z.object(SCHEMAS[t.name]);
|
|
94
|
+
const r = shape.safeParse({ node_id: VALID_ID, child_node_id: VALID_ID, ...t.extra });
|
|
95
|
+
expect(r.success).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("#1281 resolveNodeIdArg — the single resolution helper", () => {
|
|
101
|
+
test("node_id only → resolves to node_id", () => {
|
|
102
|
+
expect(resolveNodeIdArg({ node_id: "node_a" })).toEqual({ ok: true, node_id: "node_a" });
|
|
103
|
+
});
|
|
104
|
+
test("child_node_id only → resolves to it (back-compat)", () => {
|
|
105
|
+
expect(resolveNodeIdArg({ child_node_id: "node_b" })).toEqual({ ok: true, node_id: "node_b" });
|
|
106
|
+
});
|
|
107
|
+
test("both present + EQUAL → resolves (no conflict)", () => {
|
|
108
|
+
expect(resolveNodeIdArg({ node_id: "node_c", child_node_id: "node_c" })).toEqual({ ok: true, node_id: "node_c" });
|
|
109
|
+
});
|
|
110
|
+
test("both present + DIFFERENT → node_id_conflict (catches A/B fat-finger)", () => {
|
|
111
|
+
const r = resolveNodeIdArg({ node_id: "node_a", child_node_id: "node_b" });
|
|
112
|
+
expect(r.ok).toBe(false);
|
|
113
|
+
expect((r as any).error).toBe("node_id_conflict");
|
|
114
|
+
});
|
|
115
|
+
test("neither present → node_id_required", () => {
|
|
116
|
+
const r = resolveNodeIdArg({});
|
|
117
|
+
expect(r.ok).toBe(false);
|
|
118
|
+
expect((r as any).error).toBe("node_id_required");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("#1281 real handler path — stop_node routes both names to the same place", () => {
|
|
123
|
+
// stop_node calls resolveNodeIdArg FIRST, then resolveDaemonForChild. A
|
|
124
|
+
// fabricated id has no create-request row → daemon_not_resolvable. The
|
|
125
|
+
// point is that BOTH names get PAST the id gate to that same downstream
|
|
126
|
+
// error (proving the alias flows through), and the two error branches fire.
|
|
127
|
+
test("node_id (new name) flows through → daemon_not_resolvable, NOT node_id_required", async () => {
|
|
128
|
+
const r = await call("stop_node", { node_id: VALID_ID });
|
|
129
|
+
expect(r.ok).toBe(false);
|
|
130
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
131
|
+
});
|
|
132
|
+
test("child_node_id (old name) flows through → daemon_not_resolvable (back-compat intact)", async () => {
|
|
133
|
+
const r = await call("stop_node", { child_node_id: VALID_ID });
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
expect(r.error).toBe("daemon_not_resolvable");
|
|
136
|
+
});
|
|
137
|
+
test("neither → node_id_required (before any daemon resolution)", async () => {
|
|
138
|
+
const r = await call("stop_node", { force: true });
|
|
139
|
+
expect(r.ok).toBe(false);
|
|
140
|
+
expect(r.error).toBe("node_id_required");
|
|
141
|
+
});
|
|
142
|
+
test("both differ → node_id_conflict (before any daemon resolution)", async () => {
|
|
143
|
+
const r = await call("stop_node", { node_id: "node_a", child_node_id: "node_b" });
|
|
144
|
+
expect(r.ok).toBe(false);
|
|
145
|
+
expect(r.error).toBe("node_id_conflict");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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
|
+
}
|