@sleep2agi/commhub-server 0.9.0-preview.33 → 0.9.0-preview.35

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.33",
3
+ "version": "0.9.0-preview.35",
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",
package/src/db.ts CHANGED
@@ -200,6 +200,8 @@ db.exec(`
200
200
  started_at TEXT,
201
201
  runtime_submitted_at TEXT,
202
202
  consumed_at TEXT,
203
+ thread_id TEXT,
204
+ turn_id TEXT,
203
205
  completed_at TEXT,
204
206
  expires_at TEXT
205
207
  );
@@ -1157,6 +1159,11 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
1157
1159
  // started_at (legacy report_status/content matching). Old binaries ignore them.
1158
1160
  try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
1159
1161
  try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
1162
+ // Exact vendor-runtime boundary for SideThread-capable clients. These values
1163
+ // are written only by the token-bound target node after attributable runtime
1164
+ // evidence; the Hub never derives them from task text or caller input.
1165
+ try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
1166
+ try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
1160
1167
 
1161
1168
  // #1181 durable outbound terminal cursor. A task's created_at cannot be used
1162
1169
  // as a recovery watermark because an old task may become terminal after newer
@@ -52,6 +52,7 @@ const GOLDEN_RESPONSE_KEYS = {
52
52
  "status", "content", "result", "in_reply_to", "requires_response", "scope",
53
53
  "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
54
54
  "completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
55
+ "thread_id", "turn_id",
55
56
  ],
56
57
  audit: [
57
58
  "id", "user_id", "username", "action", "target_type", "target_id", "detail",
@@ -36,7 +36,7 @@ export const TASK_REST_COLUMNS = [
36
36
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
37
37
  "status", "content", "result", "in_reply_to", "requires_response", "scope",
38
38
  "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
39
- "network_id", "parent_task_id", "meta_json",
39
+ "network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
40
40
  ] as const;
41
41
 
42
42
  export const COMPLETION_REST_COLUMNS = [
@@ -92,6 +92,13 @@ function runtimeSubmittedAt(taskId: string): string | null {
92
92
  )?.runtime_submitted_at ?? null;
93
93
  }
94
94
 
95
+ function runtimeContext(taskId: string): { thread_id: string | null; turn_id: string | null } {
96
+ return db.get<{ thread_id: string | null; turn_id: string | null }>(
97
+ "SELECT thread_id, turn_id FROM tasks WHERE task_id = ?1",
98
+ taskId,
99
+ )!;
100
+ }
101
+
95
102
  beforeEach(() => { cleanup(); seed(); });
96
103
  afterAll(cleanup);
97
104
 
@@ -218,6 +225,72 @@ describe("task consumed_at identity and lifecycle", () => {
218
225
  expect(listedTask?.consumed_at).toBe(timestamp);
219
226
  });
220
227
 
228
+ test("token-bound consumed evidence persists and exposes an exact BTW boundary", async () => {
229
+ const taskId = await createTask("runtime emitted exact Codex context");
230
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
231
+ const context = { task_id: taskId, thread_id: "thread_btw_exact", turn_id: "turn_btw_exact" };
232
+
233
+ const first = await call(handler, { task_ids: [taskId], task_contexts: [context] });
234
+ expect(first.ok).toBe(true);
235
+ expect(first.tasks[0]).toMatchObject(context);
236
+ expect(runtimeContext(taskId)).toEqual({
237
+ thread_id: context.thread_id,
238
+ turn_id: context.turn_id,
239
+ });
240
+
241
+ const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
242
+ expect(listed.tasks.find((task: any) => task.task_id === taskId)).toMatchObject(context);
243
+
244
+ const replay = await call(handler, { task_ids: [taskId], task_contexts: [context] });
245
+ expect(replay.ok).toBe(true);
246
+ const conflict = await call(handler, {
247
+ task_ids: [taskId],
248
+ task_contexts: [{ ...context, turn_id: "turn_sibling" }],
249
+ });
250
+ expect(conflict).toEqual({
251
+ ok: false,
252
+ error: "task_runtime_context_conflict",
253
+ task_id: taskId,
254
+ });
255
+ expect(runtimeContext(taskId)).toEqual({
256
+ thread_id: context.thread_id,
257
+ turn_id: context.turn_id,
258
+ });
259
+ });
260
+
261
+ test("runtime context rejects user tokens, foreign tasks, and unrequested ids without partial writes", async () => {
262
+ const ownTask = await createTask("owned context", NODE_A);
263
+ const foreignTask = await createTask("foreign context", NODE_B);
264
+ const ownContext = { task_id: ownTask, thread_id: "thread_owned", turn_id: "turn_owned" };
265
+ const foreignContext = { task_id: foreignTask, thread_id: "thread_foreign", turn_id: "turn_foreign" };
266
+
267
+ expect(await call(toolsFor().mark_tasks_consumed, {
268
+ task_ids: [ownTask], task_contexts: [ownContext],
269
+ })).toEqual({ ok: false, error: "node_token_required" });
270
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
271
+ task_ids: [ownTask, foreignTask], task_contexts: [ownContext, foreignContext],
272
+ })).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
273
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
274
+ task_ids: [ownTask], task_contexts: [foreignContext],
275
+ })).toEqual({ ok: false, error: "task_context_not_requested", task_id: foreignTask });
276
+ expect(runtimeContext(ownTask)).toEqual({ thread_id: null, turn_id: null });
277
+ expect(runtimeContext(foreignTask)).toEqual({ thread_id: null, turn_id: null });
278
+ });
279
+
280
+ test("submitted evidence cannot smuggle a runtime context", async () => {
281
+ const taskId = await createTask("submission has no authoritative turn yet");
282
+ const result = await call(
283
+ toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted,
284
+ {
285
+ task_ids: [taskId],
286
+ task_contexts: [{ task_id: taskId, thread_id: "thread_early", turn_id: "turn_early" }],
287
+ },
288
+ );
289
+ expect(result).toEqual({ ok: false, error: "runtime_context_requires_consumed" });
290
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
291
+ expect(runtimeContext(taskId)).toEqual({ thread_id: null, turn_id: null });
292
+ });
293
+
221
294
  test("one runtime wake can mark an exact batch of owned tasks", async () => {
222
295
  const firstTask = await createTask("batched wake first");
223
296
  const secondTask = await createTask("batched wake second");
package/src/tools.ts CHANGED
@@ -991,6 +991,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
991
991
  const markTaskRuntimeEvidence = (
992
992
  taskIds: string[],
993
993
  level: "submitted" | "consumed",
994
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
994
995
  ) => {
995
996
  const task_ids = taskIds;
996
997
  if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
@@ -1017,6 +1018,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1017
1018
  };
1018
1019
  }
1019
1020
 
1021
+ if (level !== "consumed" && taskContexts.length > 0) {
1022
+ return {
1023
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1024
+ };
1025
+ }
1026
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1027
+ if (contextByTaskId.size !== taskContexts.length) {
1028
+ return {
1029
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1030
+ };
1031
+ }
1032
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1033
+ if (foreignContext) {
1034
+ return {
1035
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1036
+ };
1037
+ }
1038
+
1020
1039
  const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1021
1040
 
1022
1041
  // Keep ownership preflight and every stamp in one SQLite transaction.
@@ -1031,8 +1050,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1031
1050
  enforceNetworkId,
1032
1051
  canonicalCaller,
1033
1052
  );
1034
- const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
1035
- `SELECT task_id, to_node_id, to_name FROM tasks
1053
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1054
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1036
1055
  WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1037
1056
  enforceNetworkId,
1038
1057
  ...uniqueTaskIds,
@@ -1050,6 +1069,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1050
1069
  if (rejectedTaskId) {
1051
1070
  return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1052
1071
  }
1072
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1073
+ const context = contextByTaskId.get(taskId);
1074
+ if (!context) return false;
1075
+ const row = owned.get(taskId)!;
1076
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1077
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1078
+ });
1079
+ if (conflictingContext) {
1080
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1081
+ }
1053
1082
 
1054
1083
  for (const taskId of uniqueTaskIds) {
1055
1084
  const row = owned.get(taskId)!;
@@ -1059,12 +1088,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1059
1088
  const ownerValue = row.to_node_id ?? row.to_name;
1060
1089
  let updateResult;
1061
1090
  if (level === "consumed") {
1091
+ const context = contextByTaskId.get(taskId);
1062
1092
  updateResult = db.run(
1063
1093
  `UPDATE tasks SET
1064
1094
  runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1065
- consumed_at = COALESCE(consumed_at, datetime('now'))
1095
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1096
+ thread_id = COALESCE(thread_id, ?4),
1097
+ turn_id = COALESCE(turn_id, ?5)
1066
1098
  WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1067
- [enforceNetworkId, taskId, ownerValue],
1099
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1068
1100
  );
1069
1101
  } else {
1070
1102
  updateResult = db.run(
@@ -1083,8 +1115,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1083
1115
  task_id: string;
1084
1116
  runtime_submitted_at: string;
1085
1117
  consumed_at: string | null;
1118
+ thread_id: string | null;
1119
+ turn_id: string | null;
1086
1120
  }>(
1087
- `SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
1121
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1088
1122
  WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1089
1123
  enforceNetworkId,
1090
1124
  ...uniqueTaskIds,
@@ -1102,20 +1136,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1102
1136
 
1103
1137
  const taskRuntimeEvidenceSchema = {
1104
1138
  task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1139
+ task_contexts: z.array(z.object({
1140
+ task_id: z.string().min(1).max(200),
1141
+ thread_id: z.string().min(1).max(500),
1142
+ turn_id: z.string().min(1).max(500),
1143
+ }).strict()).max(100).optional(),
1105
1144
  };
1106
1145
 
1107
1146
  server.tool(
1108
1147
  "mark_tasks_runtime_submitted",
1109
1148
  "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1110
1149
  taskRuntimeEvidenceSchema,
1111
- async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
1150
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1112
1151
  );
1113
1152
 
1114
1153
  server.tool(
1115
1154
  "mark_tasks_consumed",
1116
1155
  "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1117
1156
  taskRuntimeEvidenceSchema,
1118
- async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
1157
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1119
1158
  );
1120
1159
 
1121
1160
  // ═══════════════════════════════════════════
@@ -2031,7 +2070,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2031
2070
  const tasks = db.all(
2032
2071
  `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2033
2072
  t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2034
- t.consumed_at, t.completed_at, e.terminal_seq
2073
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2035
2074
  FROM task_terminal_events e
2036
2075
  JOIN tasks t ON t.task_id = e.task_id
2037
2076
  WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
@@ -2048,7 +2087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2048
2087
  has_more: tasks.length === limit,
2049
2088
  }) }] };
2050
2089
  }
2051
- let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, completed_at FROM tasks WHERE 1=1";
2090
+ let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
2052
2091
  const params: any[] = [];
2053
2092
  sql = addReadScope(sql, params, readScope);
2054
2093
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }