@sleep2agi/commhub-server 0.9.0-preview.114 → 0.9.0-preview.115
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/create-node-unicode-name.test.ts +2 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-token-privilege-http.test.ts +9 -0
- package/src/rename.ts +15 -0
- package/src/schedule-agent-mcp-http.test.ts +18 -0
- package/src/schedule-agent.ts +3 -0
- package/src/tools.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.115",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -96,6 +96,8 @@ describe("#652 create_node via /mcp — Unicode names", () => {
|
|
|
96
96
|
test("illegal characters are refused with reason + char + message, and no row is written", async () => {
|
|
97
97
|
for (const [name, reason] of [["a/b", "forbidden_char"], ["a\\b", "forbidden_char"], ["a:b", "forbidden_char"],
|
|
98
98
|
[".hidden", "forbidden_char"], ["..", "forbidden_char"], ["a b", "forbidden_char"], ["a\u0007b", "forbidden_char"],
|
|
99
|
+
["a\nb", "forbidden_char"], ["a\tb", "forbidden_char"], ["a\u001fb", "forbidden_char"], ["a\u007fb", "forbidden_char"],
|
|
100
|
+
["a\u2028b", "forbidden_char"], ["a\u2029b", "forbidden_char"],
|
|
99
101
|
["-x", "leading_dash"], ["测".repeat(65), "too_long"]] as const) {
|
|
100
102
|
const r = await create(name);
|
|
101
103
|
expect({ name, error: r.error, reason: r.reason }).toEqual({ name, error: "node_name_invalid", reason });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// C0/C1 controls plus Unicode's two dedicated line separators. The latter are
|
|
2
|
+
// not General_Category=Cc, but they still split log/source-prefix lines.
|
|
3
|
+
const CONTROL_CHARACTER = /[\p{Cc}\u2028\u2029]/u;
|
|
4
|
+
|
|
5
|
+
export function aliasControlCharacter(alias: string): string | null {
|
|
6
|
+
return [...alias].find((char) => CONTROL_CHARACTER.test(char)) ?? null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function describeAliasControlCharacter(char: string): string {
|
|
10
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
11
|
+
return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
12
|
+
}
|
|
@@ -129,6 +129,15 @@ describe("account and network management need a user token", () => {
|
|
|
129
129
|
expect(mine.body.ok).toBe(true);
|
|
130
130
|
expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
|
|
131
131
|
});
|
|
132
|
+
test("rename rejects line breaks, tabs and other control characters before reserving an alias", async () => {
|
|
133
|
+
for (const newAlias of ["hidden\nnode", "hidden\tnode", "hidden\u001fnode", "hidden\u007fnode", "hidden\u2028node", "hidden\u2029node"]) {
|
|
134
|
+
const r = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: newAlias });
|
|
135
|
+
expect(r.status).toBe(400);
|
|
136
|
+
expect(r.body).toMatchObject({ ok: false, error: "node_alias_invalid", code: "node_alias_invalid", reason: "control_character" });
|
|
137
|
+
expect(r.body.message).toContain("control character U+");
|
|
138
|
+
}
|
|
139
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM rename_txn WHERE network_id = ?1 AND status = 'prepared'", NET)?.n).toBe(0);
|
|
140
|
+
});
|
|
132
141
|
});
|
|
133
142
|
|
|
134
143
|
describe("/api/auth/me says what the credential is", () => {
|
package/src/rename.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
|
18
18
|
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
19
19
|
import { pushEvent } from "./push";
|
|
20
20
|
import { eventBus } from "./event_bus";
|
|
21
|
+
import { aliasControlCharacter, describeAliasControlCharacter } from "./node-alias-control";
|
|
21
22
|
|
|
22
23
|
export interface RenameResult {
|
|
23
24
|
ok: boolean;
|
|
@@ -25,6 +26,9 @@ export interface RenameResult {
|
|
|
25
26
|
error?: string;
|
|
26
27
|
code?: string;
|
|
27
28
|
suggested?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
char?: string;
|
|
31
|
+
message?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface CanonicalAlias {
|
|
@@ -111,6 +115,17 @@ export function prepareRename(
|
|
|
111
115
|
): RenameResult {
|
|
112
116
|
if (!hasWriteAccess(userId, networkId)) return { ok: false, error: "no write access to this network" };
|
|
113
117
|
if (!oldAlias || !newAlias) return { ok: false, error: "old and new alias required" };
|
|
118
|
+
const control = aliasControlCharacter(newAlias);
|
|
119
|
+
if (control !== null) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
code: "node_alias_invalid",
|
|
123
|
+
error: "node_alias_invalid",
|
|
124
|
+
reason: "control_character",
|
|
125
|
+
char: control,
|
|
126
|
+
message: `alias cannot contain control character ${describeAliasControlCharacter(control)}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
114
129
|
if (oldAlias === newAlias) return { ok: false, error: "old and new alias are identical" };
|
|
115
130
|
|
|
116
131
|
// old-alias must exist as a session in this network
|
|
@@ -108,6 +108,24 @@ describe("happy path from a node", () => {
|
|
|
108
108
|
expect((await mcp(node.a.token, "schedule_run_now", { schedule_id: s.schedule_id })).error).toBe("schedule_cancelled");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("base_revision prevents a stale Agent update; omitting it keeps legacy last-write behavior", async () => {
|
|
112
|
+
const made = (await create("a")).schedule;
|
|
113
|
+
const baseRevision = made.revision;
|
|
114
|
+
const first = await mcp(node.a.token, "schedule_update", {
|
|
115
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "first writer",
|
|
116
|
+
});
|
|
117
|
+
expect(first).toMatchObject({ ok: true, schedule: { name: "first writer", revision: baseRevision + 1 } });
|
|
118
|
+
|
|
119
|
+
const stale = await mcp(node.a.token, "schedule_update", {
|
|
120
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "stale writer",
|
|
121
|
+
});
|
|
122
|
+
expect(stale).toEqual({ ok: false, error: "revision_conflict", current_revision: baseRevision + 1 });
|
|
123
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: made.schedule_id })).schedule.name).toBe("first writer");
|
|
124
|
+
|
|
125
|
+
const legacy = await mcp(node.a.token, "schedule_update", { schedule_id: made.schedule_id, name: "legacy writer" });
|
|
126
|
+
expect(legacy).toMatchObject({ ok: true, schedule: { name: "legacy writer", revision: baseRevision + 2 } });
|
|
127
|
+
});
|
|
128
|
+
|
|
111
129
|
test("a schedule for another node the caller can send_task to; the target can read it but not change it", async () => {
|
|
112
130
|
const c = await create("a", { target_node_id: node.b.id });
|
|
113
131
|
expect(c.ok).toBe(true);
|
package/src/schedule-agent.ts
CHANGED
|
@@ -126,6 +126,9 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
|
|
|
126
126
|
if (gate) return gate;
|
|
127
127
|
const row = ownRow(id, scheduleId);
|
|
128
128
|
if (!isRow(row)) return row;
|
|
129
|
+
if (body.base_revision !== undefined && Number(body.base_revision) !== row.revision) {
|
|
130
|
+
return fail("revision_conflict", { current_revision: row.revision });
|
|
131
|
+
}
|
|
129
132
|
const retarget = body.target_node_id !== undefined && body.target_node_id !== row.target_node_id;
|
|
130
133
|
let target = { node_id: row.target_node_id, alias: row.target_alias };
|
|
131
134
|
if (retarget) {
|
package/src/tools.ts
CHANGED
|
@@ -6392,7 +6392,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6392
6392
|
server.tool("schedule_create", "Schedule a task to target_node_id (default you).", schedFields,
|
|
6393
6393
|
async (args) => scheduleCall((id, w) => agentCreateSchedule(id, w, args as Record<string, unknown>)));
|
|
6394
6394
|
server.tool("schedule_update", "Edit your schedule; status=paused|active.",
|
|
6395
|
-
{ ...schedFields, schedule_id: schedId, name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
|
|
6395
|
+
{ ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
|
|
6396
6396
|
async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
|
|
6397
6397
|
server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
|
|
6398
6398
|
async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
|