@sleep2agi/commhub-server 0.9.0-preview.22 → 0.9.0-preview.24
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/package.json +1 -1
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/db.ts +32 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/health-redaction.test.ts +450 -1
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/push.ts +9 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +280 -13
- package/src/tools.ts +18 -2
|
@@ -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
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -110,6 +110,15 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
110
110
|
// legacy ?network_id= path, and master token is root already.
|
|
111
111
|
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
112
112
|
|
|
113
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
114
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
115
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
116
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
117
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
118
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
119
|
+
* silently over-filtered observer keys. */
|
|
120
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
121
|
+
|
|
113
122
|
function observerKey(networkId: string): string {
|
|
114
123
|
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
115
124
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #498 regression — the send_reply hub response gains a `warning` field
|
|
7
|
+
// pointing at commhub_send_task ONLY when the target alias resolves to
|
|
8
|
+
// a real agent node (i.e. not hub / not api / not an unbound alias).
|
|
9
|
+
// Silence-affirms the Dashboard path.
|
|
10
|
+
//
|
|
11
|
+
// Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
|
|
12
|
+
// hint — deleting it doesn't break any behavior, so it's exactly the
|
|
13
|
+
// kind of code a future refactor drops silently. This test is the
|
|
14
|
+
// tripwire: if the branch goes away, this test turns red.
|
|
15
|
+
//
|
|
16
|
+
// witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
|
|
17
|
+
// I confirmed both assertions fail cleanly when the `warning` branch is
|
|
18
|
+
// reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
|
|
19
|
+
// this suite genuinely catches regressions rather than green-when-empty.
|
|
20
|
+
|
|
21
|
+
const NET_ID = "net_498_warn";
|
|
22
|
+
const USER_ID = "u_498_warn";
|
|
23
|
+
const NODE_ID = "node_498_warn";
|
|
24
|
+
const AGENT_ALIAS = "peer-agent-498";
|
|
25
|
+
const AGENT_TOK_ID = "tok_498_warn";
|
|
26
|
+
|
|
27
|
+
interface ToolHandler {
|
|
28
|
+
(args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
29
|
+
}
|
|
30
|
+
interface Reply {
|
|
31
|
+
ok?: boolean;
|
|
32
|
+
message_id?: string;
|
|
33
|
+
session_status?: string;
|
|
34
|
+
warning?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
41
|
+
try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
44
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
45
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
46
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
47
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
48
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(cleanup);
|
|
52
|
+
afterAll(cleanup);
|
|
53
|
+
|
|
54
|
+
function seed() {
|
|
55
|
+
db.run(
|
|
56
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
57
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
58
|
+
[USER_ID, USER_ID],
|
|
59
|
+
);
|
|
60
|
+
db.run(
|
|
61
|
+
`INSERT INTO networks (network_id, network_name, owner_id, created_at)
|
|
62
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
63
|
+
[NET_ID, NET_ID, USER_ID],
|
|
64
|
+
);
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
67
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
68
|
+
[USER_ID, NET_ID],
|
|
69
|
+
);
|
|
70
|
+
db.run(
|
|
71
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
72
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
|
|
73
|
+
[NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
|
|
74
|
+
);
|
|
75
|
+
db.run(
|
|
76
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
77
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
78
|
+
[`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
|
|
79
|
+
);
|
|
80
|
+
db.run(
|
|
81
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
82
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
83
|
+
[AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeTask(to: string): string {
|
|
88
|
+
const id = "t_" + Math.random().toString(36).slice(2, 14);
|
|
89
|
+
db.run(
|
|
90
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
|
|
91
|
+
VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
|
|
92
|
+
[id, "dispatcher", to, NET_ID],
|
|
93
|
+
);
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildHandlers(): Record<string, ToolHandler> {
|
|
98
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
99
|
+
const tools: Record<string, ToolHandler> = {};
|
|
100
|
+
const origTool = server.tool.bind(server);
|
|
101
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
102
|
+
tools[name] = handler;
|
|
103
|
+
return origTool(name, _desc, _schema, handler);
|
|
104
|
+
};
|
|
105
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
106
|
+
if (origRegisterTool) {
|
|
107
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
108
|
+
tools[name] = handler;
|
|
109
|
+
return origRegisterTool(name, _cfg, handler);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Bind as a network token whose caller alias is the sender.
|
|
113
|
+
registerTools(
|
|
114
|
+
server,
|
|
115
|
+
undefined,
|
|
116
|
+
/* enforceNetworkId */ NET_ID,
|
|
117
|
+
/* enforceUserId */ USER_ID,
|
|
118
|
+
/* callerAlias */ AGENT_ALIAS,
|
|
119
|
+
/* callerTokenIsNetwork */ true,
|
|
120
|
+
/* callerTokenId */ AGENT_TOK_ID,
|
|
121
|
+
);
|
|
122
|
+
return tools;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
126
|
+
const r = await handler(args);
|
|
127
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("send_reply warning field (#498)", () => {
|
|
131
|
+
test("target alias resolves to a real agent → response includes `warning` field", async () => {
|
|
132
|
+
seed();
|
|
133
|
+
const tools = buildHandlers();
|
|
134
|
+
const sendReply = tools["send_reply"];
|
|
135
|
+
expect(sendReply).toBeDefined();
|
|
136
|
+
|
|
137
|
+
const taskId = makeTask(AGENT_ALIAS);
|
|
138
|
+
const reply = await call(sendReply, {
|
|
139
|
+
alias: AGENT_ALIAS,
|
|
140
|
+
text: "peer reply (should trigger warning)",
|
|
141
|
+
in_reply_to: taskId,
|
|
142
|
+
status: "replied",
|
|
143
|
+
from_session: AGENT_ALIAS,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(reply.ok).toBe(true);
|
|
147
|
+
expect(reply.warning).toBeDefined();
|
|
148
|
+
expect(typeof reply.warning).toBe("string");
|
|
149
|
+
const w = reply.warning as string;
|
|
150
|
+
// Action-pointing content — witnessed-red methodology means each
|
|
151
|
+
// assertion here must be one a future revert would break, not a
|
|
152
|
+
// shape-only check that passes on empty string.
|
|
153
|
+
expect(w).toContain("commhub_send_task");
|
|
154
|
+
// Alias value is INLINED (not just "<alias>") so the LLM has a
|
|
155
|
+
// copy-pasteable next command. This is the specific design 通信龙
|
|
156
|
+
// called out on 2026-07-30.
|
|
157
|
+
expect(w).toContain(`alias="${AGENT_ALIAS}"`);
|
|
158
|
+
expect(w).toContain("agent peers");
|
|
159
|
+
expect(w).toContain("not see this reply in real time");
|
|
160
|
+
expect(w).toContain("RFC-030");
|
|
161
|
+
expect(w).toContain("全网规则");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
|
|
165
|
+
seed();
|
|
166
|
+
const tools = buildHandlers();
|
|
167
|
+
const sendReply = tools["send_reply"];
|
|
168
|
+
|
|
169
|
+
const taskId = makeTask("hub");
|
|
170
|
+
const reply = await call(sendReply, {
|
|
171
|
+
alias: "hub",
|
|
172
|
+
text: "dashboard reply",
|
|
173
|
+
in_reply_to: taskId,
|
|
174
|
+
status: "replied",
|
|
175
|
+
from_session: AGENT_ALIAS,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(reply.ok).toBe(true);
|
|
179
|
+
// Silence-affirms the Dashboard path. Every reply carrying a
|
|
180
|
+
// warning would train the LLM to ignore it, so absence here is
|
|
181
|
+
// load-bearing.
|
|
182
|
+
expect(reply.warning).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("target alias is `api` (Dashboard path) → NO warning field", async () => {
|
|
186
|
+
seed();
|
|
187
|
+
const tools = buildHandlers();
|
|
188
|
+
const sendReply = tools["send_reply"];
|
|
189
|
+
|
|
190
|
+
const taskId = makeTask("api");
|
|
191
|
+
const reply = await call(sendReply, {
|
|
192
|
+
alias: "api",
|
|
193
|
+
text: "dashboard reply via api",
|
|
194
|
+
in_reply_to: taskId,
|
|
195
|
+
status: "replied",
|
|
196
|
+
from_session: AGENT_ALIAS,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(reply.ok).toBe(true);
|
|
200
|
+
expect(reply.warning).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|