@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.114",
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);
@@ -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)));